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>
Laura Barber <laura.c.barber@gmail.com> <artzii.laura@gmail.com>
John Cass <john.cass77@gmail.com>
Ronald Zielaznicki <zielaznickiz@g.cofc.edu>
Ronald Zielaznicki <zielaznickizm@g.cofc.edu> <zielaznickiz@g.cofc.edu>
Tom Roth <rawdlite@googlemail.com>

View File

@ -52,4 +52,10 @@
- John Cass <john.cass77@gmail.com>
- Laura Barber <laura.c.barber@gmail.com>
- Jakab Kristóf <jaksi07c8@gmail.com>
- Ronald Zielaznicki <zielaznickiz@g.cofc.edu>
- Ronald Zielaznicki <zielaznickizm@g.cofc.edu>
- Wojciech Wnętrzak <w.wnetrzak@gmail.com>
- Camilo Nova <camilo.nova@gmail.com>
- Dražen Lučanin <kermit666@gmail.com>
- Naglis Jonaitis <njonaitis@gmail.com>
- Tom Roth <rawdlite@googlemail.com>
- Mark Greenwood <fatgerman@gmail.com>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,79 +1,253 @@
.. _core-api:
********
Core API
********
*******************************
:mod:`mopidy.core` --- Core API
*******************************
.. module:: mopidy.core
:synopsis: Core API for use by frontends
The core API is the interface that is used by frontends like
:mod:`mopidy.http` and :mod:`mopidy.mpd`. The core layer is inbetween the
frontends and the backends.
:mod:`mopidy.http` and :mod:`mopidy.mpd`. The core layer is in between the
frontends and the backends. Don't forget that you will be accessing core
as a Pykka actor. If you are only interested in being notified about changes
in core see :class:`~mopidy.core.CoreListener`.
.. versionchanged:: 1.1
All core API calls are now type checked.
.. versionchanged:: 1.1
All backend return values are now type checked.
.. autoclass:: mopidy.core.Core
:members:
.. attribute:: tracklist
Playback controller
===================
Manages everything related to the list of tracks we will play.
See :class:`~mopidy.core.TracklistController`.
Manages playback, with actions like play, pause, stop, next, previous,
seek, and volume control.
.. attribute:: playback
.. autoclass:: mopidy.core.PlaybackState
:members:
Manages playback state and the current playing track.
See :class:`~mopidy.core.PlaybackController`.
.. autoclass:: mopidy.core.PlaybackController
:members:
.. attribute:: library
Manages the music library, e.g. searching and browsing for music.
See :class:`~mopidy.core.LibraryController`.
.. attribute:: playlists
Manages stored playlists. See :class:`~mopidy.core.PlaylistsController`.
.. attribute:: mixer
Manages volume and muting. See :class:`~mopidy.core.MixerController`.
.. attribute:: history
Keeps record of what tracks have been played.
See :class:`~mopidy.core.HistoryController`.
.. automethod:: get_uri_schemes
.. automethod:: get_version
Tracklist controller
====================
Manages everything related to the tracks we are currently playing.
.. autoclass:: mopidy.core.TracklistController
:members:
Manipulating
------------
.. automethod:: mopidy.core.TracklistController.add
.. automethod:: mopidy.core.TracklistController.remove
.. automethod:: mopidy.core.TracklistController.clear
.. automethod:: mopidy.core.TracklistController.move
.. automethod:: mopidy.core.TracklistController.shuffle
Current state
-------------
.. automethod:: mopidy.core.TracklistController.get_tl_tracks
.. automethod:: mopidy.core.TracklistController.index
.. automethod:: mopidy.core.TracklistController.get_version
.. automethod:: mopidy.core.TracklistController.get_length
.. automethod:: mopidy.core.TracklistController.get_tracks
.. automethod:: mopidy.core.TracklistController.slice
.. automethod:: mopidy.core.TracklistController.filter
Future state
------------
.. automethod:: mopidy.core.TracklistController.get_eot_tlid
.. automethod:: mopidy.core.TracklistController.get_next_tlid
.. automethod:: mopidy.core.TracklistController.get_previous_tlid
.. automethod:: mopidy.core.TracklistController.eot_track
.. automethod:: mopidy.core.TracklistController.next_track
.. automethod:: mopidy.core.TracklistController.previous_track
Options
-------
.. automethod:: mopidy.core.TracklistController.get_consume
.. automethod:: mopidy.core.TracklistController.set_consume
.. automethod:: mopidy.core.TracklistController.get_random
.. automethod:: mopidy.core.TracklistController.set_random
.. automethod:: mopidy.core.TracklistController.get_repeat
.. automethod:: mopidy.core.TracklistController.set_repeat
.. automethod:: mopidy.core.TracklistController.get_single
.. automethod:: mopidy.core.TracklistController.set_single
History controller
==================
Playback controller
===================
Keeps record of what tracks have been played.
.. autoclass:: mopidy.core.PlaybackController
.. autoclass:: mopidy.core.HistoryController
:members:
Playback control
----------------
.. automethod:: mopidy.core.PlaybackController.play
.. automethod:: mopidy.core.PlaybackController.next
.. automethod:: mopidy.core.PlaybackController.previous
.. automethod:: mopidy.core.PlaybackController.stop
.. automethod:: mopidy.core.PlaybackController.pause
.. automethod:: mopidy.core.PlaybackController.resume
.. automethod:: mopidy.core.PlaybackController.seek
Playlists controller
====================
Current track
-------------
Manages persistence of playlists.
.. automethod:: mopidy.core.PlaybackController.get_current_tl_track
.. automethod:: mopidy.core.PlaybackController.get_current_track
.. automethod:: mopidy.core.PlaybackController.get_stream_title
.. automethod:: mopidy.core.PlaybackController.get_time_position
.. autoclass:: mopidy.core.PlaylistsController
:members:
Playback states
---------------
.. automethod:: mopidy.core.PlaybackController.get_state
.. automethod:: mopidy.core.PlaybackController.set_state
.. class:: mopidy.core.PlaybackState
.. attribute:: STOPPED
:annotation: = 'stopped'
.. attribute:: PLAYING
:annotation: = 'playing'
.. attribute:: PAUSED
:annotation: = 'paused'
Library controller
==================
Manages the music library, e.g. searching for tracks to be added to a playlist.
.. class:: mopidy.core.LibraryController
.. autoclass:: mopidy.core.LibraryController
:members:
.. automethod:: mopidy.core.LibraryController.browse
.. automethod:: mopidy.core.LibraryController.search
.. automethod:: mopidy.core.LibraryController.lookup
.. automethod:: mopidy.core.LibraryController.refresh
.. automethod:: mopidy.core.LibraryController.get_images
.. automethod:: mopidy.core.LibraryController.get_distinct
Playlists controller
====================
.. class:: mopidy.core.PlaylistsController
Fetching
--------
.. automethod:: mopidy.core.PlaylistsController.as_list
.. automethod:: mopidy.core.PlaylistsController.get_items
.. automethod:: mopidy.core.PlaylistsController.lookup
.. automethod:: mopidy.core.PlaylistsController.refresh
Manipulating
------------
.. automethod:: mopidy.core.PlaylistsController.create
.. automethod:: mopidy.core.PlaylistsController.save
.. automethod:: mopidy.core.PlaylistsController.delete
Mixer controller
================
Manages volume and muting.
.. class:: mopidy.core.MixerController
.. autoclass:: mopidy.core.MixerController
:members:
.. automethod:: mopidy.core.MixerController.get_mute
.. automethod:: mopidy.core.MixerController.set_mute
.. automethod:: mopidy.core.MixerController.get_volume
.. automethod:: mopidy.core.MixerController.set_volume
Core listener
=============
History controller
==================
.. class:: mopidy.core.HistoryController
.. automethod:: mopidy.core.HistoryController.get_history
.. automethod:: mopidy.core.HistoryController.get_length
Core events
===========
.. autoclass:: mopidy.core.CoreListener
:members:
Deprecated API features
=======================
.. warning::
Though these features still work, they are slated to go away in the next
major Mopidy release.
Core
----
.. autoattribute:: mopidy.core.Core.version
.. autoattribute:: mopidy.core.Core.uri_schemes
TracklistController
-------------------
.. autoattribute:: mopidy.core.TracklistController.tl_tracks
.. autoattribute:: mopidy.core.TracklistController.tracks
.. autoattribute:: mopidy.core.TracklistController.version
.. autoattribute:: mopidy.core.TracklistController.length
.. autoattribute:: mopidy.core.TracklistController.consume
.. autoattribute:: mopidy.core.TracklistController.random
.. autoattribute:: mopidy.core.TracklistController.repeat
.. autoattribute:: mopidy.core.TracklistController.single
PlaylistsController
-------------------
.. automethod:: mopidy.core.PlaybackController.get_mute
.. automethod:: mopidy.core.PlaybackController.get_volume
.. autoattribute:: mopidy.core.PlaybackController.current_tl_track
.. autoattribute:: mopidy.core.PlaybackController.current_track
.. autoattribute:: mopidy.core.PlaybackController.state
.. autoattribute:: mopidy.core.PlaybackController.time_position
.. autoattribute:: mopidy.core.PlaybackController.mute
.. autoattribute:: mopidy.core.PlaybackController.volume
LibraryController
-----------------
.. automethod:: mopidy.core.LibraryController.find_exact
PlaybackController
------------------
.. automethod:: mopidy.core.PlaylistsController.filter
.. automethod:: mopidy.core.PlaylistsController.get_playlists
.. autoattribute:: mopidy.core.PlaylistsController.playlists

View File

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

View File

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

View File

@ -4,9 +4,6 @@
HTTP JSON-RPC API
*****************
.. module:: mopidy.http
:synopsis: The HTTP frontend APIs
The :ref:`ext-http` extension makes Mopidy's :ref:`core-api` available using
JSON-RPC over HTTP using HTTP POST and WebSockets. We also provide a JavaScript
wrapper, called :ref:`Mopidy.js <mopidy-js>`, around the JSON-RPC over
@ -65,14 +62,9 @@ JSON-RPC 2.0 messages can be recognized by checking for the key named
please refer to the `JSON-RPC 2.0 spec
<http://www.jsonrpc.org/specification>`_.
All methods (not attributes) in the :ref:`core-api` is made available through
JSON-RPC calls over the WebSocket. For example,
:meth:`mopidy.core.PlaybackController.play` is available as the JSON-RPC method
``core.playback.play``.
The core API's attributes is made available through setters and getters. For
example, the attribute :attr:`mopidy.core.PlaybackController.current_track` is
available as the JSON-RPC method ``core.playback.get_current_track``.
All methods in the :ref:`core-api` is made available through JSON-RPC calls
over the WebSocket. For example, :meth:`mopidy.core.PlaybackController.play` is
available as the JSON-RPC method ``core.playback.play``.
Example JSON-RPC request::

9
docs/api/httpclient.rst Normal file
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
*************
.. note:: What is public?
.. note::
Only APIs documented here are public and open for use by Mopidy
extensions.
.. toctree::
:glob:
Concepts
========
concepts
.. toctree::
architecture
models
backends
Basics
======
.. toctree::
core
audio
mixer
frontends
commands
frontend
backend
ext
config
zeroconf
Web/JavaScript
==============
.. toctree::
http-server
http
js
Audio
=====
.. toctree::
audio
mixer
Utilities
=========
.. toctree::
commands
config
httpclient
zeroconf

View File

@ -21,9 +21,9 @@ available at:
You may need to adjust hostname and port for your local setup.
Thus, if you use Mopidy to host your web client, like described above, you can
load the latest version of Mopidy.js by adding the following script tag to your
HTML file:
Thus, if you use Mopidy to host your web client, like described in
:ref:`static-web-client`, you can load the latest version of Mopidy.js by
adding the following script tag to your HTML file:
.. code-block:: html
@ -189,13 +189,10 @@ you've hooked up an errback (more on that a bit later) to the promise returned
from the call, the errback will be called with a ``Mopidy.ConnectionError``
instance.
All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core
API attributes is *not* available, but that shouldn't be a problem as we've
added (undocumented) getters and setters for all of them, so you can access the
attributes as well from JavaScript. For example, the
:attr:`mopidy.core.PlaybackController.state` attribute is available in
JSON-RPC as the method ``core.playback.get_state`` and in Mopidy.js as
``mopidy.playback.getState()``.
All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. For
example, the :meth:`mopidy.core.PlaybackController.get_state` method is
available in JSON-RPC as the method ``core.playback.get_state`` and in
Mopidy.js as ``mopidy.playback.getState()``.
Both the WebSocket API and the JavaScript API are based on introspection of the
core Python API. Thus, they will always be up to date and immediately reflect
@ -218,8 +215,7 @@ by looking at the method's ``description`` and ``params`` attributes:
JSON-RPC 2.0 limits method parameters to be sent *either* by-position or
by-name. Combinations of both, like we're used to from Python, isn't supported
by JSON-RPC 2.0. To further limit this, Mopidy.js currently only supports
passing parameters by-position.
by JSON-RPC 2.0.
Obviously, you'll want to get a return value from many of your method calls.
Since everything is happening across the WebSocket and maybe even across the
@ -272,8 +268,9 @@ passing it as the second argument to ``done()``:
.done(printCurrentTrack, console.error.bind(console));
If you don't hook up an error handler function and never call ``done()`` on the
promise object, when.js will log warnings to the console that you have
unhandled errors. In general, unhandled errors will not go silently missing.
promise object, warnings will be logged to the console complaining that you
have unhandled errors. In general, unhandled errors will not go silently
missing.
The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A
<http://wiki.commonjs.org/wiki/Promises/A>`_ standard. We use the

View File

@ -1,8 +1,8 @@
.. _mixer-api:
***************
Audio mixer API
***************
***************************************
:mod:`mopidy.mixer` --- Audio mixer API
***************************************
.. module:: mopidy.mixer
: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
backends and between the backends and the MPD frontend. All fields are optional
and immutable. In other words, they can only be set through the class
constructor during instance creation.
constructor during instance creation. Additionally fields are type checked.
If you want to modify a model, use the
:meth:`~mopidy.models.ImmutableObject.copy` method. It accepts keyword
:meth:`~mopidy.models.ImmutableObject.replace` method. It accepts keyword
arguments for the parts of the model you want to change, and copies the rest of
the data from the model you call it on. Example::
@ -16,7 +16,7 @@ the data from the model you call it on. Example::
>>> track1 = Track(name='Christmas Carol', length=171)
>>> track1
Track(artists=[], length=171, name='Christmas Carol')
>>> track2 = track1.copy(length=37)
>>> track2 = track1.replace(length=37)
>>> track2
Track(artists=[], length=37, name='Christmas Carol')
>>> track1
@ -75,7 +75,31 @@ Data model helpers
==================
.. autoclass:: mopidy.models.ImmutableObject
:members:
.. autoclass:: mopidy.models.ValidatedImmutableObject
:members: replace
Data model (de)serialization
----------------------------
.. autofunction:: mopidy.models.model_json_decoder
.. autoclass:: mopidy.models.ModelJSONEncoder
.. autofunction:: mopidy.models.model_json_decoder
Data model field types
----------------------
.. autoclass:: mopidy.models.fields.Field
.. autoclass:: mopidy.models.fields.String
.. autoclass:: mopidy.models.fields.Identifier
.. autoclass:: mopidy.models.fields.URI
.. autoclass:: mopidy.models.fields.Date
.. autoclass:: mopidy.models.fields.Integer
.. autoclass:: mopidy.models.fields.Collection

View File

@ -1,8 +1,8 @@
.. _zeroconf-api:
************
Zeroconf API
************
***************************************
:mod:`mopidy.zeroconf` --- Zeroconf API
***************************************
.. module:: mopidy.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.
v1.1.0 (UNRELEASED)
===================
Core API
--------
- Calling :meth:`mopidy.core.library.LibraryController.search`` with ``kwargs``
as the query is no longer supported (PR: :issue:`1090`)
- Calling the following methods with ``kwargs`` is being deprecated.
(PR: :issue:`1090`)
- :meth:`mopidy.core.library.LibraryController.search`
- :meth:`mopidy.core.library.PlaylistsController.filter`
- :meth:`mopidy.core.library.TracklistController.filter`
- :meth:`mopidy.core.library.TracklistController.remove`
- Updated core controllers to handle backend exceptions in all calls that rely
on multiple backends. (Issue: :issue:`667`)
- Update core methods to do strict input checking. (Fixes: :issue:`700`)
- Add ``tlid`` alternatives to methods that take ``tl_track`` and also add
``get_{eot,next,previous}_tlid`` methods as light weight alternatives to the
``tl_track`` versions of the calls. (Fixes: :issue:`1131` PR: :issue:`1136`,
:issue:`1140`)
- Add :meth:`mopidy.core.playback.PlaybackController.get_current_tlid`.
(Part of: :issue:`1137`)
- Update core to handle backend crashes and bad data. (Fixes: :issue:`1161`)
- Add `max_tracklist_length` config and limitation. (Fixes: :issue:`997`
PR: :issue:`1225`)
Models
------
- Added type checks and other sanity checks to model construction and
serialization. (Fixes: :issue:`865`)
- Memory usage for models has been greatly improved. We now have a lower
overhead per instance by using slots, intern identifiers and automatically
reuse instances. For the test data set this was developed against, a library
of ~14000 tracks, went from needing ~75MB to ~17MB. (Fixes: :issue:`348`)
MPD frontend
------------
- The MPD command ``count`` now ignores tracks with no length, which would
previously cause a :exc:`TypeError`. (PR: :issue:`1192`)
- Concatenate multiple artists, composers and performers using the "A;B" format
instead of "A, B". This is a part of updating our protocol implementation to
match MPD 0.19. (PR: :issue:`1213`)
- Add "not implemented" skeletons of new commands in the MPD protocol version
0.19:
- Current playlist:
- ``rangeid``
- ``addtagid``
- ``cleartagid``
- Mounts and neighbors:
- ``mount``
- ``unmount``
- ``listmounts``
- ``listneighbors``
- Music DB:
- ``listfiles``
- Track data now include the ``Last-Modified`` field if set on the track model.
(Fixes: :issue:`1218`, PR: :issue:`1219`)
Local backend
-------------
- Filter out :class:`None` from
:meth:`~mopidy.backend.LibraryProvider.get_distinct` results. All returned
results should be strings. (Fixes: :issue:`1202`)
File backend
------------
The :ref:`Mopidy-File <ext-file>` backend is a new bundled backend. It is
similar to Mopidy-Local since it works with local files, but it differs in a
few key ways:
- Mopidy-File lets you browse your media files by their file hierarchy.
- It supports multiple media directories, all exposed under the "Files"
directory when you browse your library with e.g. an MPD client.
- There is no index of the media files, like the JSON or SQLite files used by
Mopidy-Local. Thus no need to scan the music collection before starting
Mopidy. Everything is read from the file system when needed and changes to
the file system is thus immediately visible in Mopidy clients.
- Because there is no index, there is no support for search.
Our long term plan is to keep this very simple file backend in Mopidy, as it
has a well defined and limited scope, while splitting the more feature rich
Mopidy-Local extension out to an independent project. (Fixes: :issue:`1004`,
PR: :issue:`1207`)
Utils
-----
- Add :func:`mopidy.httpclient.format_proxy` and
:func:`mopidy.httpclient.format_user_agent`. (Part of: :issue:`1156`)
Internal changes
----------------
@ -20,19 +123,130 @@ Internal changes
- Tests have been cleaned up to stop using deprecated APIs where feasible.
(Partial fix: :issue:`1083`, PR: :issue:`1090`)
- It is now possible to import :mod:`mopidy.backends` without having GObject or
GStreamer installed. In other words, a lot of backend extensions should now
be able to run tests in a virtualenv with global site-packages disabled. This
removes a lot of potential error sources. (Fixes: :issue:`1068`, PR:
:issue:`1115`)
v1.0.1 (UNRELEASED)
v1.0.8 (2015-07-22)
===================
Bug fix release.
- Fix reversal of ``Title`` and ``Name`` in MPD protocol (Fixes: :issue:`1212`
PR: :issue:`1214`)
- Fix crash if an M3U file in the :confval:`m3u/playlist_dir` directory has a
file name not decodable with the current file system encoding. (Fixes:
:issue:`1209`)
v1.0.7 (2015-06-26)
===================
Bug fix release.
- Fix error in the MPD command ``list title ...``. The error was introduced in
v1.0.6.
v1.0.6 (2015-06-25)
===================
Bug fix release.
- Core/MPD/Local: Add support for ``title`` in
:meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`1181`,
PR: :issue:`1183`)
- Core: Make sure track changes make it to audio while paused.
(Fixes: :issue:`1177`, PR: :issue:`1185`)
v1.0.5 (2015-05-19)
===================
Bug fix release.
- Core: Add workaround for playlist providers that do not support
creating playlists. (Fixes: :issue:`1162`, PR :issue:`1165`)
- M3U: Fix encoding error when saving playlists with non-ASCII track
titles. (Fixes: :issue:`1175`, PR :issue:`1176`)
v1.0.4 (2015-04-30)
===================
Bug fix release.
- Audio: Since all previous attempts at tweaking the queuing for :issue:`1097`
seems to break things in subtle ways for different users. We are giving up
on tweaking the defaults and just going to live with a bit more lag on
software volume changes. (Fixes: :issue:`1147`)
v1.0.3 (2015-04-28)
===================
Bug fix release.
- HTTP: Another follow-up to the Tornado <3.0 fixing. Since the tests aren't
run for Tornado 2.3 we didn't catch that our previous fix wasn't sufficient.
(Fixes: :issue:`1153`, PR: :issue:`1154`)
- Audio: Follow-up fix for :issue:`1097` still exhibits issues for certain
setups. We are giving this get an other go by setting the buffer size to
maximum 100ms instead of a fixed number of buffers. (Addresses: :issue:`1147`,
PR: :issue:`1154`)
v1.0.2 (2015-04-27)
===================
Bug fix release.
- HTTP: Make event broadcasts work with Tornado 2.3 again. The threading fix
in v1.0.1 broke this.
- Audio: Fix for :issue:`1097` tuned down the buffer size in the queue. Turns
out this can cause distortions in certain cases. Give this an other go with
a more generous buffer size. (Addresses: :issue:`1147`, PR: :issue:`1152`)
- Audio: Make sure mute events get emitted by software mixer.
(Fixes: :issue:`1146`, PR: :issue:`1152`)
v1.0.1 (2015-04-23)
===================
Bug fix release.
- Core: Make the new history controller available for use. (Fixes: :js:`6`)
- Audio: Software volume control has been reworked to greatly reduce the delay
between changing the volume and the change taking effect. (Fixes:
:issue:`1097`)
:issue:`1097`, PR: :issue:`1101`)
- Audio: As a side effect of the previous bug fix, software volume is no longer
tied to the PulseAudio application volume when using ``pulsesink``. This
behavior was confusing for many users and doesn't work well with the plans
for multiple outputs.
- Audio: Update scanner to decode all media it finds. This should fix cases
where the scanner hangs on non-audio files like video. The scanner will now
also let us know if we found any decodeable audio. (Fixes: :issue:`726`, PR:
issue:`1124`)
- HTTP: Fix threading bug that would cause duplicate delivery of WS messages.
(PR: :issue:`1127`)
- MPD: Fix case where a playlist that is present in both browse and as a listed
playlist breaks the MPD frontend protocol output. (Fixes :issue:`1120`, PR:
:issue:`1142`)
v1.0.0 (2015-03-25)
===================

View File

@ -12,38 +12,8 @@ http://mpd.wikia.com/wiki/Clients.
:local:
Test procedure
==============
In some cases, we've used the following test procedure to compare the feature
completeness of clients:
#. Connect to Mopidy
#. Search for "foo", with search type "any" if it can be selected
#. Add "The Pretender" from the search results to the current playlist
#. Start playback
#. Pause and resume playback
#. Adjust volume
#. Find a playlist and append it to the current playlist
#. Skip to next track
#. Skip to previous track
#. Select the last track from the current playlist
#. Turn on repeat mode
#. Seek to 10 seconds or so before the end of the track
#. Wait for the end of the track and confirm that playback continues at the
start of the playlist
#. Turn off repeat mode
#. Turn on random mode
#. Skip to next track and confirm that it random mode works
#. Turn off random mode
#. Stop playback
#. Check if the app got support for single mode and consume mode
#. Kill Mopidy and confirm that the app handles it without crashing
Console clients
===============
MPD console clients
===================
ncmpcpp
-------
@ -83,8 +53,8 @@ A command line client. Version 0.16 and upwards seems to work nicely with
Mopidy.
Graphical clients
=================
MPD graphical clients
=====================
GMPC
----
@ -132,22 +102,12 @@ client for OS X. It is unmaintained, but generally works well with Mopidy.
.. _android_mpd_clients:
Android clients
===============
We've tested all five MPD clients we could find for Android with Mopidy 0.8.1
on a Samsung Galaxy Nexus with Android 4.1.2, using our standard test
procedure.
MPD Android clients
===================
MPDroid
-------
Test date:
2012-11-06
Tested version:
1.03.1 (released 2012-10-16)
.. image:: mpd-client-mpdroid.jpg
:width: 288
:height: 512
@ -155,128 +115,17 @@ Tested version:
You can get `MPDroid from Google Play
<https://play.google.com/store/apps/details?id=com.namelessdev.mpdroid>`_.
- MPDroid started out as a fork of PMix, and is now much better.
- MPDroid's user interface looks nice.
- Everything in the test procedure works.
- In contrast to all other Android clients, MPDroid does support single mode or
consume mode.
- When Mopidy is killed, MPDroid handles it gracefully and asks if you want to
try to reconnect.
MPDroid is a good MPD client, and really the only one we can recommend.
BitMPC
------
Test date:
2012-11-06
Tested version:
1.0.0 (released 2010-04-12)
You can get `BitMPC from Google Play
<https://play.google.com/store/apps/details?id=bitendian.bitmpc>`_.
- The user interface lacks some finishing touches. E.g. you can't enter a
hostname for the server. Only IPv4 addresses are allowed.
- When we last tested the same version of BitMPC using Android 2.1:
- All features exercised in the test procedure worked.
- BitMPC lacked support for single mode and consume mode.
- BitMPC crashed if Mopidy was killed or crashed.
- When we tried to test using Android 4.1.1, BitMPC started and connected to
Mopidy without problems, but the app crashed as soon as we fired off our
search, and continued to crash on startup after that.
In conclusion, BitMPC is usable if you got an older Android phone and don't
care about looks. For newer Android versions, BitMPC will probably not work as
it hasn't been maintained for 2.5 years.
Droid MPD Client
----------------
Test date:
2012-11-06
Tested version:
1.4.0 (released 2011-12-20)
You can get `Droid MPD Client from Google Play
<https://play.google.com/store/apps/details?id=com.soreha.droidmpdclient>`_.
- No intutive way to ask the app to connect to the server after adding the
server hostname to the settings.
- To find the search functionality, you have to select the menu,
then "Playlist manager", then the search tab. I do not understand why search
is hidden inside "Playlist manager".
- The tabs "Artists" and "Albums" did not contain anything, and did not cause
any requests.
- The tab "Folders" showed a spinner and said "Updating data..." but did not
send any requests.
- Searching for "foo" did nothing. No request was sent to the server.
- Droid MPD client does not support single mode or consume mode.
- Not able to complete the test procedure, due to the above problems.
In conclusion, not a client we can recommend.
PMix
----
Test date:
2012-11-06
Tested version:
0.4.0 (released 2010-03-06)
You can get `PMix from Google Play
<https://play.google.com/store/apps/details?id=org.pmix.ui>`_.
PMix haven't been updated for 2.5 years, and has less working features than
it's fork MPDroid. Ignore PMix and use MPDroid instead.
MPD Remote
----------
Test date:
2012-11-06
Tested version:
1.0 (released 2012-05-01)
You can get `MPD Remote from Google Play
<https://play.google.com/store/apps/details?id=fr.mildlyusefulsoftware.mpdremote>`_.
This app looks terrible in the screen shots, got just 100+ downloads, and got a
terrible rating. I honestly didn't take the time to test it.
.. _ios_mpd_clients:
iOS clients
===========
MPD iOS clients
===============
MPoD
----
Test date:
2012-11-06
Tested version:
1.7.1
.. image:: mpd-client-mpod.jpg
:width: 320
:height: 480
@ -285,26 +134,10 @@ The `MPoD <http://www.katoemba.net/makesnosenseatall/mpod/>`_ iPhone/iPod Touch
app can be installed from `MPoD at iTunes Store
<https://itunes.apple.com/us/app/mpod/id285063020>`_.
- The user interface looks nice.
- All features exercised in the test procedure worked with MPaD, except seek,
which I didn't figure out to do.
- Search only works in the "Browse" tab, and not under in the "Artist",
"Album", or "Song" tabs. For the tabs where search doesn't work, no queries
are sent to Mopidy when searching.
- Single mode and consume mode is supported.
MPaD
----
Test date:
2012-11-06
Tested version:
1.7.1
.. image:: mpd-client-mpad.jpg
:width: 480
:height: 360
@ -313,25 +146,11 @@ The `MPaD <http://www.katoemba.net/makesnosenseatall/mpad/>`_ iPad app can be
purchased from `MPaD at iTunes Store
<https://itunes.apple.com/us/app/mpad/id423097706>`_
- The user interface looks nice, though I would like to be able to view the
current playlist in the large part of the split view.
- All features exercised in the test procedure worked with MPaD.
- Search only works in the "Browse" tab, and not under in the "Artist",
"Album", or "Song" tabs. For the tabs where search doesn't work, no queries
are sent to Mopidy when searching.
- Single mode and consume mode is supported.
- The server menu can be very slow top open, and there is no visible feedback
when waiting for the connection to a server to succeed.
.. _mpd-web-clients:
Web clients
===========
MPD web clients
===============
The following web clients use the MPD protocol to communicate with Mopidy. For
other web clients, see :ref:`http-clients`.

View File

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

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
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:

View File

@ -57,6 +57,28 @@ Provides a backend for browsing the Internet radio channels from the `Dirble
<http://dirble.com/>`_ directory.
Mopidy-dLeyna
=============
https://github.com/tkem/mopidy-dleyna
Provides a backend for playing music from Digital Media Servers using
the `dLeyna <http://01.org/dleyna>`_ D-Bus interface.
Mopidy-File
===========
Bundled with Mopidy. See :ref:`ext-file`.
Mopidy-Grooveshark
==================
https://github.com/camilonova/mopidy-grooveshark
Provides a backend for playing music from `Grooveshark
<http://grooveshark.com/>`_.
Mopidy-GMusic
=============

47
docs/ext/file.rst Normal file
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.
Mopidy-dam1021
==============
https://github.com/fortaa/mopidy-dam1021
Extension for controlling volume using a dam1021 DAC device.
Mopidy-NAD
==========

BIN
docs/ext/mopster.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -48,6 +48,22 @@ To install, run::
pip install Mopidy-Local-Images
Mopidy-Material-Webclient
=========================
https://github.com/matgallacher/mopidy-material-webclient
A Mopidy web client with an Android Material design feel.
.. image:: /ext/material_webclient.png
:width: 960
:height: 520
To install, run::
pip install Mopidy-Material-Webclient
Mopidy-Mobile
=============
@ -143,6 +159,21 @@ A web extension for changing settings. Used by the Pi MusicBox distribution
for Raspberry Pi, but also usable for other projects.
Mopster
=======
https://github.com/cowbell/mopster
Simple web client hosted online written in Ember.js and styled using basic
Bootstrap by Wojciech Wnętrzak.
.. image:: /ext/mopster.png
:width: 1275
:height: 628
To use, just visit http://mopster.cowbell-labs.com/.
Other web clients
=================

View File

@ -6,7 +6,7 @@ Extension development
Mopidy started as simply an MPD server that could play music from Spotify.
Early on, Mopidy got multiple "frontends" to expose Mopidy to more than just MPD
clients: for example the scrobbler frontend that scrobbles your listening
clients: for example the scrobbler frontend that scrobbles your listening
history to your Last.fm account, the MPRIS frontend that integrates Mopidy into the
Ubuntu Sound Menu, and the HTTP server and JavaScript player API making web
based Mopidy clients possible. In Mopidy 0.9 we added support for multiple
@ -75,10 +75,10 @@ the readme of `cookiecutter-mopidy-ext
Example README.rst
==================
The README file should quickly explain what the extension does, how to install
it, and how to configure it. It should also contain a link to a tarball of the
latest development version of the extension. It's important that this link ends
with ``#egg=Mopidy-Something-dev`` for installation using
The README file should quickly explain what the extension does, how to install
it, and how to configure it. It should also contain a link to a tarball of the
latest development version of the extension. It's important that this link ends
with ``#egg=Mopidy-Something-dev`` for installation using
``pip install Mopidy-Something==dev`` to work.
.. code-block:: rst
@ -230,8 +230,8 @@ The root of your Python package should have an ``__version__`` attribute with a
class named ``Extension`` which inherits from Mopidy's extension base class,
:class:`mopidy.ext.Extension`. This is the class referred to in the
``entry_points`` part of ``setup.py``. Any imports of other files in your
extension, outside of Mopidy and it's core requirements, should be kept inside
methods. This ensures that this file can be imported without raising
extension, outside of Mopidy and it's core requirements, should be kept inside
methods. This ensures that this file can be imported without raising
:exc:`ImportError` exceptions for missing dependencies, etc.
The default configuration for the extension is defined by the
@ -245,7 +245,7 @@ change them. The exception is if the config value has security implications; in
that case you should default to the most secure configuration. Leave any
configurations that don't have meaningful defaults blank, like ``username``
and ``password``. In the example below, we've chosen to maintain the default
config as a separate file named ``ext.conf``. This makes it easy to include the
config as a separate file named ``ext.conf``. This makes it easy to include the
default config in documentation without duplicating it.
This is ``mopidy_soundspot/__init__.py``::
@ -413,11 +413,11 @@ examples, see the :ref:`http-server-api` docs or explore with
Running an extension
====================
Once your extension is ready to go, to see it in action you'll need to register
it with Mopidy. Typically this is done by running ``python setup.py install``
from your extension's Git repo root directory. While developing your extension
and to avoid doing this every time you make a change, you can instead run
``python setup.py develop`` to effectively link Mopidy directly with your
Once your extension is ready to go, to see it in action you'll need to register
it with Mopidy. Typically this is done by running ``python setup.py install``
from your extension's Git repo root directory. While developing your extension
and to avoid doing this every time you make a change, you can instead run
``python setup.py develop`` to effectively link Mopidy directly with your
development files.
@ -434,9 +434,12 @@ Use of Mopidy APIs
==================
When writing an extension, you should only use APIs documented at
:ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.utils`, may change at
any time and are not something extensions should use.
:ref:`api-ref`. Other parts of Mopidy, like :mod:`mopidy.internal`, may change
at any time and are not something extensions should use.
Mopidy performs type checking to help catch extension bugs. This applies to
both to frontend calls into core and return values from backends. Additionally
model fields always get validated to further guard against bad data.
Logging in extensions
=====================
@ -471,3 +474,76 @@ Is much better than::
If you want to turn on debug logging for your own extension, but not for
everything else due to the amount of noise, see the docs for the
:confval:`loglevels/*` config section.
Making HTTP requests from extensions
====================================
Many Mopidy extensions need to make HTTP requests to use some web API. Here's a
few recommendations to those extensions.
Proxies
-------
If you make HTTP requests please make sure to respect the :ref:`proxy configs
<proxy-config>`, so that all the requests you make go through the proxy
configured by the Mopidy user. To make this easier for extension developers,
the helper function :func:`mopidy.httpclient.format_proxy` was added in Mopidy
1.1. This function returns the proxy settings `formatted the way Requests
expects <http://www.python-requests.org/en/latest/user/advanced/#proxies>`__.
User-Agent strings
------------------
When you make HTTP requests, it's helpful for debugging and usage analysis if
the client identifies itself with a proper User-Agent string. In Mopidy 1.1, we
added the helper function :func:`mopidy.httpclient.format_user_agent`. Here's
an example of how to use it::
>>> from mopidy import httpclient
>>> import mopidy_soundspot
>>> httpclient.format_user_agent('%s/%s' % (
... mopidy_soundspot.Extension.dist_name, mopidy_soundspot.__version__))
u'Mopidy-SoundSpot/2.0.0 Mopidy/1.0.7 Python/2.7.10'
Example using Requests sessions
-------------------------------
Most Mopidy extensions that make HTTP requests use the `Requests
<http://www.python-requests.org/>`_ library to do so. When using Requests, the
most convenient way to make sure the proxy and User-Agent header is set
properly is to create a Requests session object and use that object to make all
your HTTP requests::
from mopidy import httpclient
import requests
import mopidy_soundspot
def get_requests_session(proxy_config, user_agent):
proxy = httpclient.format_proxy(proxy_config)
full_user_agent = httpclient.format_user_agent(user_agent)
session = requests.Session()
session.proxies.update({'http': proxy, 'https': proxy})
session.headers.update({'user-agent': full_user_agent})
return session
# ``mopidy_config`` is the config object passed to your frontend/backend
# constructor
session = get_requests_session(
proxy_config=mopidy_config['proxy'],
user_agent='%s/%s' % (
mopidy_soundspot.Extension.dist_name,
mopidy_soundspot.__version__))
response = session.get('http://example.com')
# Now do something with ``response`` and/or make further requests using the
# ``session`` object.
For further details, see Requests' docs on `session objects
<http://www.python-requests.org/en/latest/user/advanced/#session-objects>`__.

View File

@ -6,7 +6,7 @@ Glossary
backend
A part of Mopidy providing music library, playlist storage and/or
playback capability to the :term:`core`. Mopidy have a backend for each
playback capability to the :term:`core`. Mopidy has a backend for each
music store or music service it supports. See :ref:`backend-api` for
details.

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

View File

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

View File

@ -18,12 +18,12 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See
The packages should work with:
- Debian stable and testing,
- Raspbian stable and testing,
- Debian stable ("jessie") and testing ("stretch"),
- Raspbian stable ("jessie") and testing ("stretch"),
- Ubuntu 14.04 LTS and later.
Some of the packages, including the core "mopidy" packages, does *not* work
on Ubuntu 12.04 LTS.
Some of the packages *do not* work with Ubuntu 12.04 LTS or Debian 7
"wheezy".
This is just what we currently support, not a promise to continue to
support the same in the future. We *will* drop support for older
@ -47,6 +47,13 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See
sudo wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/mopidy.list
.. note::
If you're still running Debian 7 "wheezy" or Raspbian "wheezy", you
should edit :file:`/etc/apt/sources.list.d/mopidy.list` and replace
"stable" with "wheezy". This will give you the latest set of packages
that is compatible with Debian "wheezy".
#. Install Mopidy and all dependencies::
sudo apt-get update

View File

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

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
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
<debian-install>` or :ref:`from AUR <arch-install>`, you can install Mopidy
<debian-install>` or :ref:`from the Arch Linux repository <arch-install>`, you can install Mopidy
from PyPI using the ``pip`` installer.
If you are looking to contribute or wish to install from source using ``git``

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`.
.. automodule:: mopidy.local
:synopsis: Local backend
Local library API
=================
.. autoclass:: mopidy.local.Library
:members:
Translation utils
=================
.. automodule:: mopidy.local.translator
:synopsis: Translators for local library extensions
:members:

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`.
@ -71,6 +71,14 @@ Current playlist
:members:
Mounts and neighbors
--------------------
.. automodule:: mopidy.mpd.protocol.mount
:synopsis: MPD protocol: mounts and neighbors
:members:
Music database
--------------

View File

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

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
:doc:`/clients/mpd` list to find recommended clients.
Updating the library
====================
To update the library, e.g. after audio files have changed, run::
mopidy local scan
Afterwards, to refresh the library (which is for now only available
through the API) it is necessary to run::
curl -d '{"jsonrpc": "2.0", "id": 1, "method": "core.library.refresh"}' http://localhost:6680/mopidy/rpc
This makes the changes in the library visible to the clients.
Stopping Mopidy
===============
@ -33,8 +47,8 @@ Init scripts
<https://github.com/mopidy/mopidy/blob/debian/debian/mopidy.init>`_. For
more details, see the :ref:`debian` section of the docs.
- The ``mopidy`` package in `Arch Linux AUR
<https://aur.archlinux.org/packages/mopidy>`__ comes with a systemd init
- The ``mopidy`` package in `Arch Linux
<https://www.archlinux.org/packages/community/any/mopidy/>`__ comes with a systemd init
script.
- A blog post by Benjamin Guillet explains how to `Daemonize Mopidy and Launch

View File

@ -20,12 +20,6 @@ for free. We use their services for the following sites:
- Mailgun for sending emails from the Discourse forum.
- Hosting of the Jenkins CI server at https://ci.mopidy.com.
- Hosting of a Linux worker for https://ci.mopidy.com.
- Hosting of a Windows worker for https://ci.mopidy.com.
- CDN hosting at http://dl.mopidy.com, which is used to distribute Pi Musicbox
images.

View File

@ -2,7 +2,6 @@ from __future__ import absolute_import, print_function, unicode_literals
import platform
import sys
import textwrap
import warnings
@ -11,23 +10,8 @@ if not (2, 7) <= sys.version_info < (3,):
'ERROR: Mopidy requires Python 2.7, but found %s.' %
platform.python_version())
try:
import gobject # noqa
except ImportError:
print(textwrap.dedent("""
ERROR: The gobject Python package was not found.
Mopidy requires GStreamer (and GObject) to work. These are C libraries
with a number of dependencies themselves, and cannot be installed with
the regular Python tools like pip.
Please see http://docs.mopidy.com/en/latest/installation/ for
instructions on how to install the required dependencies.
"""))
raise
warnings.filterwarnings('ignore', 'could not open display')
__version__ = '1.0.0'
__version__ = '1.0.8'

View File

@ -4,8 +4,23 @@ import logging
import os
import signal
import sys
import textwrap
try:
import gobject # noqa
except ImportError:
print(textwrap.dedent("""
ERROR: The gobject Python package was not found.
Mopidy requires GStreamer (and GObject) to work. These are C libraries
with a number of dependencies themselves, and cannot be installed with
the regular Python tools like pip.
Please see http://docs.mopidy.com/en/latest/installation/ for
instructions on how to install the required dependencies.
"""))
raise
import gobject
gobject.threads_init()
try:
@ -26,7 +41,7 @@ sys.argv[1:] = []
from mopidy import commands, config as config_lib, ext
from mopidy.utils import encoding, log, path, process, versioning
from mopidy.internal import encoding, log, path, process, versioning
logger = logging.getLogger(__name__)
@ -51,21 +66,23 @@ def main():
root_cmd.add_child('config', config_cmd)
root_cmd.add_child('deps', deps_cmd)
installed_extensions = ext.load_extensions()
extensions_data = ext.load_extensions()
for extension in installed_extensions:
ext_cmd = extension.get_command()
if ext_cmd:
ext_cmd.set(extension=extension)
root_cmd.add_child(extension.ext_name, ext_cmd)
for data in extensions_data:
if data.command: # TODO: check isinstance?
data.command.set(extension=data.extension)
root_cmd.add_child(data.extension.ext_name, data.command)
args = root_cmd.parse(mopidy_args)
create_file_structures_and_config(args, installed_extensions)
create_file_structures_and_config(args, extensions_data)
check_old_locations()
config, config_errors = config_lib.load(
args.config_files, installed_extensions, args.config_overrides)
args.config_files,
[d.config_schema for d in extensions_data],
[d.config_defaults for d in extensions_data],
args.config_overrides)
verbosity_level = args.base_verbosity_level
if args.verbosity_level:
@ -75,8 +92,11 @@ def main():
extensions = {
'validate': [], 'config': [], 'disabled': [], 'enabled': []}
for extension in installed_extensions:
if not ext.validate_extension(extension):
for data in extensions_data:
extension = data.extension
# TODO: factor out all of this to a helper that can be tested
if not ext.validate_extension_data(data):
config[extension.ext_name] = {'enabled': False}
config_errors[extension.ext_name] = {
'enabled': 'extension disabled by self check.'}
@ -94,12 +114,13 @@ def main():
else:
extensions['enabled'].append(extension)
log_extension_info(installed_extensions, extensions['enabled'])
log_extension_info([d.extension for d in extensions_data],
extensions['enabled'])
# Config and deps commands are simply special cased for now.
if args.command == config_cmd:
return args.command.run(
config, config_errors, installed_extensions)
schemas = [d.config_schema for d in extensions_data]
return args.command.run(config, config_errors, schemas)
elif args.command == deps_cmd:
return args.command.run()
@ -119,10 +140,19 @@ def main():
return 1
for extension in extensions['enabled']:
extension.setup(registry)
try:
extension.setup(registry)
except Exception:
# TODO: would be nice a transactional registry. But sadly this
# is a bit tricky since our current API is giving out a mutable
# list. We might however be able to replace this with a
# collections.Sequence to provide a RO view.
logger.exception('Extension %s failed during setup, this might'
' have left the registry in a bad state.',
extension.ext_name)
# Anything that wants to exit after this point must use
# mopidy.utils.process.exit_process as actors can have been started.
# mopidy.internal.process.exit_process as actors can have been started.
try:
return args.command.run(args, proxied_config)
except NotImplementedError:

View File

@ -8,7 +8,7 @@ import gobject
import pygst
pygst.require('0.10')
import gst # noqa
import gst.pbutils
import gst.pbutils # noqa
import pykka
@ -16,7 +16,7 @@ from mopidy import exceptions
from mopidy.audio import playlists, utils
from mopidy.audio.constants import PlaybackState
from mopidy.audio.listener import AudioListener
from mopidy.utils import deprecation, process
from mopidy.internal import deprecation, process
logger = logging.getLogger(__name__)
@ -166,10 +166,7 @@ class _Outputs(gst.Bin):
logger.info('Audio output set to "%s"', description)
def _add(self, element):
# All tee branches need a queue in front of them.
# But keep the queue short so the volume change isn't to slow:
queue = gst.element_factory_make('queue')
queue.set_property('max-size-buffers', 5)
self.add(element)
self.add(queue)
queue.link(element)
@ -199,16 +196,14 @@ class SoftwareMixer(object):
def set_volume(self, volume):
self._element.set_property('volume', volume / 100.0)
self._mixer.trigger_volume_changed(volume)
self._mixer.trigger_volume_changed(self.get_volume())
def get_mute(self):
return self._element.get_property('mute')
def set_mute(self, mute):
result = self._element.set_property('mute', bool(mute))
if result:
self._mixer.trigger_mute_changed(bool(mute))
return result
self._element.set_property('mute', bool(mute))
self._mixer.trigger_mute_changed(self.get_mute())
class _Handler(object):

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 pygst
pygst.require('0.10')
import gst # noqa
import gst.pbutils
import gst.pbutils # noqa
from mopidy import exceptions
from mopidy.audio import utils
from mopidy.utils import encoding
from mopidy.internal import encoding
_missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description
_Result = collections.namedtuple(
'Result', ('uri', 'tags', 'duration', 'seekable', 'mime'))
'Result', ('uri', 'tags', 'duration', 'seekable', 'mime', 'playable'))
_RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float')
@ -52,14 +53,14 @@ class Scanner(object):
try:
_start_pipeline(pipeline)
tags, mime = _process(pipeline, self._timeout_ms)
tags, mime, have_audio = _process(pipeline, self._timeout_ms)
duration = _query_duration(pipeline)
seekable = _query_seekable(pipeline)
finally:
pipeline.set_state(gst.STATE_NULL)
del pipeline
return _Result(uri, tags, duration, seekable, mime)
return _Result(uri, tags, duration, seekable, mime, have_audio)
# Turns out it's _much_ faster to just create a new pipeline for every as
@ -71,31 +72,39 @@ def _setup_pipeline(uri, proxy_config=None):
typefind = gst.element_factory_make('typefind')
decodebin = gst.element_factory_make('decodebin2')
sink = gst.element_factory_make('fakesink')
pipeline = gst.element_factory_make('pipeline')
for e in (src, typefind, decodebin, sink):
for e in (src, typefind, decodebin):
pipeline.add(e)
gst.element_link_many(src, typefind, decodebin)
if proxy_config:
utils.setup_proxy(src, proxy_config)
decodebin.set_property('caps', _RAW_AUDIO)
decodebin.connect('pad-added', _pad_added, sink)
typefind.connect('have-type', _have_type, decodebin)
decodebin.connect('pad-added', _pad_added, pipeline)
return pipeline
def _have_type(element, probability, caps, decodebin):
decodebin.set_property('sink-caps', caps)
msg = gst.message_new_application(element, caps.get_structure(0))
element.get_bus().post(msg)
struct = gst.Structure('have-type')
struct['caps'] = caps.get_structure(0)
element.get_bus().post(gst.message_new_application(element, struct))
def _pad_added(element, pad, sink):
return pad.link(sink.get_pad('sink'))
def _pad_added(element, pad, pipeline):
sink = gst.element_factory_make('fakesink')
sink.set_property('sync', False)
pipeline.add(sink)
sink.sync_state_with_parent()
pad.link(sink.get_pad('sink'))
if pad.get_caps().is_subset(_RAW_AUDIO):
struct = gst.Structure('have-audio')
element.get_bus().post(gst.message_new_application(element, struct))
def _start_pipeline(pipeline):
@ -125,7 +134,7 @@ def _process(pipeline, timeout_ms):
clock = pipeline.get_clock()
bus = pipeline.get_bus()
timeout = timeout_ms * gst.MSECOND
tags, mime, missing_description = {}, None, None
tags, mime, have_audio, missing_description = {}, None, False, None
types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR
| gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG)
@ -141,19 +150,22 @@ def _process(pipeline, timeout_ms):
missing_description = encoding.locale_decode(
_missing_plugin_desc(message))
elif message.type == gst.MESSAGE_APPLICATION:
mime = message.structure.get_name()
if mime.startswith('text/') or mime == 'application/xml':
return tags, mime
if message.structure.get_name() == 'have-type':
mime = message.structure['caps'].get_name()
if mime.startswith('text/') or mime == 'application/xml':
return tags, mime, have_audio
elif message.structure.get_name() == 'have-audio':
have_audio = True
elif message.type == gst.MESSAGE_ERROR:
error = encoding.locale_decode(message.parse_error()[0])
if missing_description:
error = '%s (%s)' % (missing_description, error)
raise exceptions.ScannerError(error)
elif message.type == gst.MESSAGE_EOS:
return tags, mime
return tags, mime, have_audio
elif message.type == gst.MESSAGE_ASYNC_DONE:
if message.src == pipeline:
return tags, mime
return tags, mime, have_audio
elif message.type == gst.MESSAGE_TAG:
taglist = message.parse_tag()
# Note that this will only keep the last tag.
@ -162,3 +174,28 @@ def _process(pipeline, timeout_ms):
timeout -= clock.get_time() - start
raise exceptions.ScannerError('Timeout after %dms' % timeout_ms)
if __name__ == '__main__':
import os
import sys
import gobject
from mopidy.internal import path
gobject.threads_init()
scanner = Scanner(5000)
for uri in sys.argv[1:]:
if not gst.uri_is_valid(uri):
uri = path.path_to_uri(os.path.abspath(uri))
try:
result = scanner.scan(uri)
for key in ('uri', 'mime', 'duration', 'playable', 'seekable'):
print('%-20s %s' % (key, getattr(result, key)))
print('tags')
for tag, value in result.tags.items():
print('%-20s %s' % (tag, value))
except exceptions.ScannerError as error:
print('%s: %s' % (uri, error))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,27 @@
from __future__ import absolute_import, unicode_literals
import contextlib
import logging
from mopidy import exceptions
from mopidy.internal import validation
logger = logging.getLogger(__name__)
@contextlib.contextmanager
def _mixer_error_handling(mixer):
try:
yield
except exceptions.ValidationError as e:
logger.error('%s mixer returned bad data: %s',
mixer.actor_ref.actor_class.__name__, e)
except Exception:
logger.exception('%s mixer caused an exception.',
mixer.actor_ref.actor_class.__name__)
class MixerController(object):
pykka_traversable = True
@ -19,8 +35,15 @@ class MixerController(object):
The volume scale is linear.
"""
if self._mixer is not None:
return self._mixer.get_volume().get()
if self._mixer is None:
return None
with _mixer_error_handling(self._mixer):
volume = self._mixer.get_volume().get()
volume is None or validation.check_integer(volume, min=0, max=100)
return volume
return None
def set_volume(self, volume):
"""Set the volume.
@ -31,10 +54,17 @@ class MixerController(object):
Returns :class:`True` if call is successful, otherwise :class:`False`.
"""
validation.check_integer(volume, min=0, max=100)
if self._mixer is None:
return False
else:
return self._mixer.set_volume(volume).get()
return False # TODO: 2.0 return None
with _mixer_error_handling(self._mixer):
result = self._mixer.set_volume(volume).get()
validation.check_instance(result, bool)
return result
return False
def get_mute(self):
"""Get mute state.
@ -42,8 +72,15 @@ class MixerController(object):
:class:`True` if muted, :class:`False` unmuted, :class:`None` if
unknown.
"""
if self._mixer is not None:
return self._mixer.get_mute().get()
if self._mixer is None:
return None
with _mixer_error_handling(self._mixer):
mute = self._mixer.get_mute().get()
mute is None or validation.check_instance(mute, bool)
return mute
return None
def set_mute(self, mute):
"""Set mute state.
@ -52,7 +89,13 @@ class MixerController(object):
Returns :class:`True` if call is successful, otherwise :class:`False`.
"""
validation.check_boolean(mute)
if self._mixer is None:
return False
else:
return self._mixer.set_mute(bool(mute)).get()
return False # TODO: 2.0 return None
with _mixer_error_handling(self._mixer):
result = self._mixer.set_mute(bool(mute)).get()
validation.check_instance(result, bool)
return result
return False

View File

@ -3,9 +3,10 @@ from __future__ import absolute_import, unicode_literals
import logging
import urlparse
from mopidy import models
from mopidy.audio import PlaybackState
from mopidy.core import listener
from mopidy.utils import deprecation
from mopidy.internal import deprecation, validation
logger = logging.getLogger(__name__)
@ -64,9 +65,7 @@ class PlaybackController(object):
Returns a :class:`mopidy.models.Track` or :class:`None`.
"""
tl_track = self.get_current_tl_track()
if tl_track is not None:
return tl_track.track
return getattr(self.get_current_tl_track(), 'track', None)
current_track = deprecation.deprecated_property(get_current_track)
"""
@ -74,6 +73,18 @@ class PlaybackController(object):
Use :meth:`get_current_track` instead.
"""
def get_current_tlid(self):
"""
Get the currently playing or selected TLID.
Extracted from :meth:`get_current_tl_track` for convenience.
Returns a :class:`int` or :class:`None`.
.. versionadded:: 1.1
"""
return getattr(self.get_current_tl_track(), 'tlid', None)
def get_stream_title(self):
"""Get the current stream title or :class:`None`."""
return self._stream_title
@ -100,6 +111,8 @@ class PlaybackController(object):
"PAUSED" -> "PLAYING" [ label="resume" ]
"PAUSED" -> "STOPPED" [ label="stop" ]
"""
validation.check_choice(new_state, validation.PLAYBACK_STATES)
(old_state, self._state) = (self.get_state(), new_state)
logger.debug('Changing state: %s -> %s', old_state, new_state)
@ -265,17 +278,37 @@ class PlaybackController(object):
self.set_state(PlaybackState.PAUSED)
self._trigger_track_playback_paused()
def play(self, tl_track=None):
def play(self, tl_track=None, tlid=None):
"""
Play the given track, or if the given track is :class:`None`, play the
currently active track.
Play the given track, or if the given tl_track and tlid is
:class:`None`, play the currently active track.
Note that the track **must** already be in the tracklist.
:param tl_track: track to play
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:param tlid: TLID of the track to play
:type tlid: :class:`int` or :class:`None`
"""
self._play(tl_track, on_error_step=1)
if sum(o is not None for o in [tl_track, tlid]) > 1:
raise ValueError('At most one of "tl_track" and "tlid" may be set')
tl_track is None or validation.check_instance(tl_track, models.TlTrack)
tlid is None or validation.check_integer(tlid, min=0)
if tl_track:
deprecation.warn('core.playback.play:tl_track_kwarg', pending=True)
self._play(tl_track=tl_track, tlid=tlid, on_error_step=1)
def _play(self, tl_track=None, tlid=None, on_error_step=1):
if tl_track is None and tlid is not None:
for tl_track in self.core.tracklist.get_tl_tracks():
if tl_track.tlid == tlid:
break
else:
tl_track = None
def _play(self, tl_track=None, on_error_step=1):
if tl_track is None:
if self.get_state() == PlaybackState.PAUSED:
return self.resume()
@ -382,6 +415,13 @@ class PlaybackController(object):
:rtype: :class:`True` if successful, else :class:`False`
"""
# TODO: seek needs to take pending tracks into account :(
validation.check_integer(time_position)
if time_position < 0:
logger.debug(
'Client seeked to negative position. Seeking to zero.')
time_position = 0
if not self.core.tracklist.tracks:
return False

View File

@ -1,17 +1,31 @@
from __future__ import absolute_import, unicode_literals
import contextlib
import logging
import urlparse
import pykka
from mopidy import exceptions
from mopidy.core import listener
from mopidy.models import Playlist
from mopidy.utils import deprecation
from mopidy.internal import deprecation, validation
from mopidy.models import Playlist, Ref
logger = logging.getLogger(__name__)
@contextlib.contextmanager
def _backend_error_handling(backend, reraise=None):
try:
yield
except exceptions.ValidationError as e:
logger.error('%s backend returned bad data: %s',
backend.actor_ref.actor_class.__name__, e)
except Exception as e:
if reraise and isinstance(e, reraise):
raise
logger.exception('%s backend caused an exception.',
backend.actor_ref.actor_class.__name__)
class PlaylistsController(object):
pykka_traversable = True
@ -32,14 +46,19 @@ class PlaylistsController(object):
.. versionadded:: 1.0
"""
futures = {
b.actor_ref.actor_class.__name__: b.playlists.as_list()
for b in set(self.backends.with_playlists.values())}
backend: backend.playlists.as_list()
for backend in set(self.backends.with_playlists.values())}
results = []
for backend_name, future in futures.items():
for b, future in futures.items():
try:
results.extend(future.get())
with _backend_error_handling(b, reraise=NotImplementedError):
playlists = future.get()
if playlists is not None:
validation.check_instances(playlists, Ref)
results.extend(playlists)
except NotImplementedError:
backend_name = b.actor_ref.actor_class.__name__
logger.warning(
'%s does not implement playlists.as_list(). '
'Please upgrade it.', backend_name)
@ -60,10 +79,20 @@ class PlaylistsController(object):
.. versionadded:: 1.0
"""
validation.check_uri(uri)
uri_scheme = urlparse.urlparse(uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None)
if backend:
return backend.playlists.get_items(uri).get()
if not backend:
return None
with _backend_error_handling(backend):
items = backend.playlists.get_items(uri).get()
items is None or validation.check_instances(items, Ref)
return items
return None
def get_playlists(self, include_tracks=True):
"""
@ -88,7 +117,7 @@ class PlaylistsController(object):
# Use the playlist name from as_list() because it knows about any
# playlist folder hierarchy, which lookup() does not.
return [
playlists[r.uri].copy(name=r.name)
playlists[r.uri].replace(name=r.name)
for r in playlist_refs if playlists[r.uri] is not None]
else:
return [
@ -116,16 +145,23 @@ class PlaylistsController(object):
:type name: string
:param uri_scheme: use the backend matching the URI scheme
:type uri_scheme: string
:rtype: :class:`mopidy.models.Playlist`
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
"""
if uri_scheme in self.backends.with_playlists:
backend = self.backends.with_playlists[uri_scheme]
backends = [self.backends.with_playlists[uri_scheme]]
else:
# TODO: this fallback looks suspicious
backend = list(self.backends.with_playlists.values())[0]
playlist = backend.playlists.create(name).get()
listener.CoreListener.send('playlist_changed', playlist=playlist)
return playlist
backends = self.backends.with_playlists.values()
for backend in backends:
with _backend_error_handling(backend):
result = backend.playlists.create(name).get()
if result is None:
continue
validation.check_instance(result, Playlist)
listener.CoreListener.send('playlist_changed', playlist=result)
return result
return None
def delete(self, uri):
"""
@ -137,10 +173,18 @@ class PlaylistsController(object):
:param uri: URI of the playlist to delete
:type uri: string
"""
validation.check_uri(uri)
uri_scheme = urlparse.urlparse(uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None)
if backend:
if not backend:
return
with _backend_error_handling(backend):
backend.playlists.delete(uri).get()
# TODO: emit playlist changed?
# TODO: return value?
def filter(self, criteria=None, **kwargs):
"""
@ -150,15 +194,12 @@ class PlaylistsController(object):
# Returns track with name 'a'
filter({'name': 'a'})
filter(name='a')
# Returns track with URI 'xyz'
filter({'uri': 'xyz'})
filter(uri='xyz')
# Returns track with name 'a' and URI 'xyz'
filter({'name': 'a', 'uri': 'xyz'})
filter(name='a', uri='xyz')
:param criteria: one or more criteria to match by
:type criteria: dict
@ -170,7 +211,10 @@ class PlaylistsController(object):
deprecation.warn('core.playlists.filter')
criteria = criteria or kwargs
matches = self.playlists
validation.check_query(
criteria, validation.PLAYLIST_FIELDS, list_values=False)
matches = self.playlists # TODO: stop using self playlists
for (key, value) in criteria.iteritems():
matches = filter(lambda p: getattr(p, key) == value, matches)
return matches
@ -186,11 +230,18 @@ class PlaylistsController(object):
"""
uri_scheme = urlparse.urlparse(uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None)
if backend:
return backend.playlists.lookup(uri).get()
else:
if not backend:
return None
with _backend_error_handling(backend):
playlist = backend.playlists.lookup(uri).get()
playlist is None or validation.check_instance(playlist, Playlist)
return playlist
return None
# TODO: there is an inconsistency between library.refresh(uri) and this
# call, not sure how to sort this out.
def refresh(self, uri_scheme=None):
"""
Refresh the playlists in :attr:`playlists`.
@ -203,16 +254,26 @@ class PlaylistsController(object):
:param uri_scheme: limit to the backend matching the URI scheme
:type uri_scheme: string
"""
if uri_scheme is None:
futures = [b.playlists.refresh()
for b in self.backends.with_playlists.values()]
pykka.get_all(futures)
# TODO: check: uri_scheme is None or uri_scheme?
futures = {}
backends = {}
playlists_loaded = False
for backend_scheme, backend in self.backends.with_playlists.items():
backends.setdefault(backend, set()).add(backend_scheme)
for backend, backend_schemes in backends.items():
if uri_scheme is None or uri_scheme in backend_schemes:
futures[backend] = backend.playlists.refresh()
for backend, future in futures.items():
with _backend_error_handling(backend):
future.get()
playlists_loaded = True
if playlists_loaded:
listener.CoreListener.send('playlists_loaded')
else:
backend = self.backends.with_playlists.get(uri_scheme, None)
if backend:
backend.playlists.refresh().get()
listener.CoreListener.send('playlists_loaded')
def save(self, playlist):
"""
@ -236,11 +297,23 @@ class PlaylistsController(object):
:type playlist: :class:`mopidy.models.Playlist`
:rtype: :class:`mopidy.models.Playlist` or :class:`None`
"""
validation.check_instance(playlist, Playlist)
if playlist.uri is None:
return
return # TODO: log this problem?
uri_scheme = urlparse.urlparse(playlist.uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None)
if backend:
if not backend:
return None
# TODO: we let AssertionError error through due to legacy tests :/
with _backend_error_handling(backend, reraise=AssertionError):
playlist = backend.playlists.save(playlist).get()
listener.CoreListener.send('playlist_changed', playlist=playlist)
playlist is None or validation.check_instance(playlist, Playlist)
if playlist:
listener.CoreListener.send(
'playlist_changed', playlist=playlist)
return playlist
return None

View File

@ -1,13 +1,12 @@
from __future__ import absolute_import, unicode_literals
import collections
import logging
import random
from mopidy import compat
from mopidy import exceptions
from mopidy.core import listener
from mopidy.models import TlTrack
from mopidy.utils import deprecation
from mopidy.internal import deprecation, validation
from mopidy.models import TlTrack, Track
logger = logging.getLogger(__name__)
@ -93,6 +92,7 @@ class TracklistController(object):
:class:`False`
Tracks are not removed from the tracklist.
"""
validation.check_boolean(value)
if self.get_consume() != value:
self._trigger_options_changed()
return setattr(self, '_consume', value)
@ -121,7 +121,7 @@ class TracklistController(object):
:class:`False`
Tracks are played in the order of the tracklist.
"""
validation.check_boolean(value)
if self.get_random() != value:
self._trigger_options_changed()
if value:
@ -157,7 +157,7 @@ class TracklistController(object):
:class:`False`
The tracklist is played once.
"""
validation.check_boolean(value)
if self.get_repeat() != value:
self._trigger_options_changed()
return setattr(self, '_repeat', value)
@ -188,6 +188,7 @@ class TracklistController(object):
:class:`False`
Playback continues after current song.
"""
validation.check_boolean(value)
if self.get_single() != value:
self._trigger_options_changed()
return setattr(self, '_single', value)
@ -200,18 +201,52 @@ class TracklistController(object):
# Methods
def index(self, tl_track):
def index(self, tl_track=None, tlid=None):
"""
The position of the given track in the tracklist.
If neither *tl_track* or *tlid* is given we return the index of
the currently playing track.
:param tl_track: the track to find the index of
:type tl_track: :class:`mopidy.models.TlTrack`
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:param tlid: TLID of the track to find the index of
:type tlid: :class:`int` or :class:`None`
:rtype: :class:`int` or :class:`None`
.. versionadded:: 1.1
The *tlid* parameter
"""
try:
return self._tl_tracks.index(tl_track)
except ValueError:
return None
tl_track is None or validation.check_instance(tl_track, TlTrack)
tlid is None or validation.check_integer(tlid, min=0)
if tl_track is None and tlid is None:
tl_track = self.core.playback.get_current_tl_track()
if tl_track is not None:
try:
return self._tl_tracks.index(tl_track)
except ValueError:
pass
elif tlid is not None:
for i, tl_track in enumerate(self._tl_tracks):
if tl_track.tlid == tlid:
return i
return None
def get_eot_tlid(self):
"""
The TLID of the track that will be played after the given track.
Not necessarily the same TLID as returned by :meth:`get_next_tlid`.
:rtype: :class:`int` or :class:`None`
.. versionadded:: 1.1
"""
current_tl_track = self.core.playback.get_current_tl_track()
return getattr(self.eot_track(current_tl_track), 'tlid', None)
def eot_track(self, tl_track):
"""
@ -223,6 +258,8 @@ class TracklistController(object):
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
"""
deprecation.warn('core.tracklist.eot_track', pending=True)
tl_track is None or validation.check_instance(tl_track, TlTrack)
if self.get_single() and self.get_repeat():
return tl_track
elif self.get_single():
@ -233,6 +270,23 @@ class TracklistController(object):
# shared.
return self.next_track(tl_track)
def get_next_tlid(self):
"""
The tlid of the track that will be played if calling
:meth:`mopidy.core.PlaybackController.next()`.
For normal playback this is the next track in the tracklist. If repeat
is enabled the next track can loop around the tracklist. When random is
enabled this should be a random track, all tracks should be played once
before the tracklist repeats.
:rtype: :class:`int` or :class:`None`
.. versionadded:: 1.1
"""
current_tl_track = self.core.playback.get_current_tl_track()
return getattr(self.next_track(current_tl_track), 'tlid', None)
def next_track(self, tl_track):
"""
The track that will be played if calling
@ -247,34 +301,51 @@ class TracklistController(object):
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
"""
deprecation.warn('core.tracklist.next_track', pending=True)
tl_track is None or validation.check_instance(tl_track, TlTrack)
if not self.get_tl_tracks():
if not self._tl_tracks:
return None
if self.get_random() and not self._shuffled:
if self.get_repeat() or not tl_track:
logger.debug('Shuffling tracks')
self._shuffled = self.get_tl_tracks()
self._shuffled = self._tl_tracks[:]
random.shuffle(self._shuffled)
if self.get_random():
try:
if self._shuffled:
return self._shuffled[0]
except IndexError:
return None
return None
if tl_track is None:
return self.get_tl_tracks()[0]
next_index = 0
else:
next_index = self.index(tl_track) + 1
next_index = self.index(tl_track) + 1
if self.get_repeat():
next_index %= len(self.get_tl_tracks())
try:
return self.get_tl_tracks()[next_index]
except IndexError:
next_index %= len(self._tl_tracks)
elif next_index >= len(self._tl_tracks):
return None
return self._tl_tracks[next_index]
def get_previous_tlid(self):
"""
Returns the TLID of the track that will be played if calling
:meth:`mopidy.core.PlaybackController.previous()`.
For normal playback this is the previous track in the tracklist. If
random and/or consume is enabled it should return the current track
instead.
:rtype: :class:`int` or :class:`None`
.. versionadded:: 1.1
"""
current_tl_track = self.core.playback.get_current_tl_track()
return getattr(self.previous_track(current_tl_track), 'tlid', None)
def previous_track(self, tl_track):
"""
Returns the track that will be played if calling
@ -288,6 +359,9 @@ class TracklistController(object):
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
"""
deprecation.warn('core.tracklist.previous_track', pending=True)
tl_track is None or validation.check_instance(tl_track, TlTrack)
if self.get_repeat() or self.get_consume() or self.get_random():
return tl_track
@ -296,30 +370,35 @@ class TracklistController(object):
if position in (None, 0):
return None
return self.get_tl_tracks()[position - 1]
# Since we know we are not at zero we have to be somewhere in the range
# 1 - len(tracks) Thus 'position - 1' will always be within the list.
return self._tl_tracks[position - 1]
def add(self, tracks=None, at_position=None, uri=None, uris=None):
"""
Add the track or list of tracks to the tracklist.
Add tracks to the tracklist.
If ``uri`` is given instead of ``tracks``, the URI is looked up in the
library and the resulting tracks are added to the tracklist.
If ``uris`` is given instead of ``tracks``, the URIs are looked up in
the library and the resulting tracks are added to the tracklist.
If ``uris`` is given instead of ``uri`` or ``tracks``, the URIs are
looked up in the library and the resulting tracks are added to the
tracklist.
If ``at_position`` is given, the tracks placed at the given position in
the tracklist. If ``at_position`` is not given, the tracks are appended
to the end of the tracklist.
If ``at_position`` is given, the tracks are inserted at the given
position in the tracklist. If ``at_position`` is not given, the tracks
are appended to the end of the tracklist.
Triggers the :meth:`mopidy.core.CoreListener.tracklist_changed` event.
:param tracks: tracks to add
:type tracks: list of :class:`mopidy.models.Track`
:param at_position: position in tracklist to add track
:type tracks: list of :class:`mopidy.models.Track` or :class:`None`
:param at_position: position in tracklist to add tracks
:type at_position: int or :class:`None`
:param uri: URI for tracks to add
:type uri: string
:type uri: string or :class:`None`
:param uris: list of URIs for tracks to add
:type uris: list of string or :class:`None`
:rtype: list of :class:`mopidy.models.TlTrack`
.. versionadded:: 1.0
@ -328,10 +407,14 @@ class TracklistController(object):
.. deprecated:: 1.0
The ``tracks`` and ``uri`` arguments. Use ``uris``.
"""
assert tracks is not None or uri is not None or uris is not None, \
'tracks, uri or uris must be provided'
if sum(o is not None for o in [tracks, uri, uris]) != 1:
raise ValueError(
'Exactly one of "tracks", "uri" or "uris" must be set')
# TODO: assert that tracks are track instances
tracks is None or validation.check_instances(tracks, Track)
uri is None or validation.check_uri(uri)
uris is None or validation.check_uris(uris)
validation.check_integer(at_position or 0)
if tracks:
deprecation.warn('core.tracklist.add:tracks_arg')
@ -349,8 +432,13 @@ class TracklistController(object):
tracks.extend(track_map[uri])
tl_tracks = []
max_length = self.core._config['core']['max_tracklist_length']
for track in tracks:
if self.get_length() >= max_length:
raise exceptions.TracklistFull(
'Tracklist may contain at most %d tracks.' % max_length)
tl_track = TlTrack(self._next_tlid, track)
self._next_tlid += 1
if at_position is not None:
@ -388,41 +476,35 @@ class TracklistController(object):
# Returns tracks with TLIDs 1, 2, 3, or 4 (tracklist ID)
filter({'tlid': [1, 2, 3, 4]})
filter(tlid=[1, 2, 3, 4])
# Returns track with IDs 1, 5, or 7
filter({'id': [1, 5, 7]})
filter(id=[1, 5, 7])
# Returns track with URIs 'xyz' or 'abc'
filter({'uri': ['xyz', 'abc']})
filter(uri=['xyz', 'abc'])
# Returns tracks with ID 1 and URI 'xyz'
filter({'id': [1], 'uri': ['xyz']})
filter(id=[1], uri=['xyz'])
# Returns track with a matching ID (1, 3 or 6) and a matching URI
# ('xyz' or 'abc')
filter({'id': [1, 3, 6], 'uri': ['xyz', 'abc']})
filter(id=[1, 3, 6], uri=['xyz', 'abc'])
# Returns track with a matching TLIDs (1, 3 or 6) and a
# matching URI ('xyz' or 'abc')
filter({'tlid': [1, 3, 6], 'uri': ['xyz', 'abc']})
:param criteria: on or more criteria to match by
:type criteria: dict, of (string, list) pairs
:rtype: list of :class:`mopidy.models.TlTrack`
.. deprecated:: 1.1
Providing the criteria via ``kwargs``.
"""
if kwargs:
deprecation.warn('core.tracklist.filter:kwargs_criteria')
criteria = criteria or kwargs
tlids = criteria.pop('tlid', [])
validation.check_query(criteria, validation.TRACKLIST_FIELDS)
validation.check_instances(tlids, int)
matches = self._tl_tracks
for (key, values) in criteria.items():
if (not isinstance(values, collections.Iterable) or
isinstance(values, compat.string_types)):
# Fail hard if anyone is using the <0.17 calling style
raise ValueError('Filter values must be iterable: %r' % values)
if key == 'tlid':
matches = [ct for ct in matches if ct.tlid in values]
else:
matches = [
ct for ct in matches if getattr(ct.track, key) in values]
matches = [
ct for ct in matches if getattr(ct.track, key) in values]
if tlids:
matches = [ct for ct in matches if ct.tlid in tlids]
return matches
def move(self, start, end, to_position):
@ -443,6 +525,7 @@ class TracklistController(object):
tl_tracks = self._tl_tracks
# TODO: use validation helpers?
assert start < end, 'start must be smaller than end'
assert start >= 0, 'start must be at least zero'
assert end <= len(tl_tracks), \
@ -469,8 +552,14 @@ class TracklistController(object):
:param criteria: on or more criteria to match by
:type criteria: dict
:rtype: list of :class:`mopidy.models.TlTrack` that was removed
.. deprecated:: 1.1
Providing the criteria via ``kwargs`` is no longer supported.
"""
tl_tracks = self.filter(criteria, **kwargs)
if kwargs:
deprecation.warn('core.tracklist.remove:kwargs_criteria')
tl_tracks = self.filter(criteria or kwargs)
for tl_track in tl_tracks:
position = self._tl_tracks.index(tl_track)
del self._tl_tracks[position]
@ -491,6 +580,7 @@ class TracklistController(object):
"""
tl_tracks = self._tl_tracks
# TOOD: use validation helpers?
if start is not None and end is not None:
assert start < end, 'start must be smaller than end'
@ -519,6 +609,7 @@ class TracklistController(object):
:type end: int
:rtype: :class:`mopidy.models.TlTrack`
"""
# TODO: validate slice?
return self._tl_tracks[start:end]
def _mark_playing(self, tl_track):
@ -535,13 +626,13 @@ class TracklistController(object):
def _mark_played(self, tl_track):
"""Internal method for :class:`mopidy.core.PlaybackController`."""
if self.consume and tl_track is not None:
self.remove(tlid=[tl_track.tlid])
self.remove({'tlid': [tl_track.tlid]})
return True
return False
def _trigger_tracklist_changed(self):
if self.get_random():
self._shuffled = self.get_tl_tracks()
self._shuffled = self._tl_tracks[:]
random.shuffle(self._shuffled)
else:
self._shuffled = []

View File

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

View File

@ -11,6 +11,12 @@ from mopidy import config as config_lib, exceptions
logger = logging.getLogger(__name__)
_extension_data_fields = ['extension', 'entry_point', 'config_schema',
'config_defaults', 'command']
ExtensionData = collections.namedtuple('ExtensionData', _extension_data_fields)
class Extension(object):
"""Base class for Mopidy extensions"""
@ -89,14 +95,7 @@ class Extension(object):
the ``frontend`` and ``backend`` registry keys.
This method can also be used for other setup tasks not involving the
extension registry. For example, to register custom GStreamer
elements::
def setup(self, registry):
from .mixer import SoundspotMixer
gobject.type_register(SoundspotMixer)
gst.element_register(
SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL)
extension registry.
:param registry: the extension registry
:type registry: :class:`Registry`
@ -155,55 +154,100 @@ def load_extensions():
for entry_point in pkg_resources.iter_entry_points('mopidy.ext'):
logger.debug('Loading entry point: %s', entry_point)
extension_class = entry_point.load(require=False)
extension = extension_class()
extension.entry_point = entry_point
installed_extensions.append(extension)
try:
if not issubclass(extension_class, Extension):
raise TypeError # issubclass raises TypeError on non-class
except TypeError:
logger.error('Entry point %s did not contain a valid extension'
'class: %r', entry_point.name, extension_class)
continue
try:
extension = extension_class()
config_schema = extension.get_config_schema()
default_config = extension.get_default_config()
command = extension.get_command()
except Exception:
logger.exception('Setup of extension from entry point %s failed, '
'ignoring extension.', entry_point.name)
continue
installed_extensions.append(ExtensionData(
extension, entry_point, config_schema, default_config, command))
logger.debug(
'Loaded extension: %s %s', extension.dist_name, extension.version)
names = (e.ext_name for e in installed_extensions)
names = (ed.extension.ext_name for ed in installed_extensions)
logger.debug('Discovered extensions: %s', ', '.join(names))
return installed_extensions
def validate_extension(extension):
def validate_extension_data(data):
"""Verify extension's dependencies and environment.
:param extensions: an extension to check
:returns: if extension should be run
"""
logger.debug('Validating extension: %s', extension.ext_name)
logger.debug('Validating extension: %s', data.extension.ext_name)
if extension.ext_name != extension.entry_point.name:
if data.extension.ext_name != data.entry_point.name:
logger.warning(
'Disabled extension %(ep)s: entry point name (%(ep)s) '
'does not match extension name (%(ext)s)',
{'ep': extension.entry_point.name, 'ext': extension.ext_name})
{'ep': data.entry_point.name, 'ext': data.extension.ext_name})
return False
try:
extension.entry_point.require()
data.entry_point.require()
except pkg_resources.DistributionNotFound as ex:
logger.info(
'Disabled extension %s: Dependency %s not found',
extension.ext_name, ex)
data.extension.ext_name, ex)
return False
except pkg_resources.VersionConflict as ex:
if len(ex.args) == 2:
found, required = ex.args
logger.info(
'Disabled extension %s: %s required, but found %s at %s',
extension.ext_name, required, found, found.location)
data.extension.ext_name, required, found, found.location)
else:
logger.info('Disabled extension %s: %s', extension.ext_name, ex)
logger.info(
'Disabled extension %s: %s', data.extension.ext_name, ex)
return False
try:
extension.validate_environment()
data.extension.validate_environment()
except exceptions.ExtensionError as ex:
logger.info(
'Disabled extension %s: %s', extension.ext_name, ex.message)
'Disabled extension %s: %s', data.extension.ext_name, ex.message)
return False
except Exception:
logger.exception('Validating extension %s failed with an exception.',
data.extension.ext_name)
return False
if not data.config_schema:
logger.error('Extension %s does not have a config schema, disabling.',
data.extension.ext_name)
return False
elif not isinstance(data.config_schema.get('enabled'), config_lib.Boolean):
logger.error('Extension %s does not have the required "enabled" config'
' option, disabling.', data.extension.ext_name)
return False
for key, value in data.config_schema.items():
if not isinstance(value, config_lib.ConfigValue):
logger.error('Extension %s config schema contains an invalid value'
' for the option "%s", disabling.',
data.extension.ext_name, key)
return False
if not data.config_defaults:
logger.error('Extension %s does not have a default config, disabling.',
data.extension.ext_name)
return False
return True

32
mopidy/file/__init__.py Normal file
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.core import CoreListener
from mopidy.http import handlers
from mopidy.utils import encoding, formatting, network
from mopidy.internal import encoding, formatting, network
logger = logging.getLogger(__name__)

View File

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

52
mopidy/httpclient.py Normal file
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.get_volume': 'playback.get_volume() is deprecated',
'core.playback.set_volume': 'playback.set_volume() is deprecated',
'core.playback.play:tl_track_kwargs':
'playback.play() with "tl_track" argument is pending deprecation use '
'"tlid" instead',
# Deprecated features in core playlists:
'core.playlists.filter': 'playlists.filter() is deprecated',
@ -40,11 +43,32 @@ _MESSAGES = {
'tracklist.add() "tracks" argument is deprecated',
'core.tracklist.add:uri_arg':
'tracklist.add() "uri" argument is deprecated',
'core.tracklist.filter:kwargs_criteria':
'tracklist.filter() with "kwargs" as criteria is deprecated',
'core.tracklist.remove:kwargs_criteria':
'tracklist.remove() with "kwargs" as criteria is deprecated',
'core.tracklist.eot_track':
'tracklist.eot_track() is pending deprecation, use '
'tracklist.get_eot_tlid()',
'core.tracklist.next_track':
'tracklist.next_track() is pending deprecation, use '
'tracklist.get_next_tlid()',
'core.tracklist.previous_track':
'tracklist.previous_track() is pending deprecation, use '
'tracklist.get_previous_tlid()',
'models.immutable.copy':
'ImmutableObject.copy() is deprecated, use ImmutableObject.replace()',
}
def warn(msg_id):
warnings.warn(_MESSAGES.get(msg_id, msg_id), DeprecationWarning)
def warn(msg_id, pending=False):
if pending:
category = PendingDeprecationWarning
else:
category = DeprecationWarning
warnings.warn(_MESSAGES.get(msg_id, msg_id), category)
@contextlib.contextmanager

View File

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

View File

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

View File

@ -8,25 +8,15 @@ import threading
import urllib
import urlparse
import glib
from mopidy import compat, exceptions
from mopidy.compat import queue
from mopidy.utils import encoding
from mopidy.internal import encoding, xdg
logger = logging.getLogger(__name__)
XDG_DIRS = {
'XDG_CACHE_DIR': glib.get_user_cache_dir(),
'XDG_CONFIG_DIR': glib.get_user_config_dir(),
'XDG_DATA_DIR': glib.get_user_data_dir(),
'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC),
}
# XDG_MUSIC_DIR can be none, so filter out any bad data.
XDG_DIRS = dict((k, v) for k, v in XDG_DIRS.items() if v is not None)
XDG_DIRS = xdg.get_dirs()
def get_or_create_dir(dir_path):
@ -206,23 +196,23 @@ def find_mtimes(root, follow=False):
return mtimes, errors
def check_file_path_is_inside_base_dir(file_path, base_path):
assert not file_path.endswith(os.sep), (
'File path %s cannot end with a path separator' % file_path)
def is_path_inside_base_dir(path, base_path):
if path.endswith(os.sep):
raise ValueError('Path %s cannot end with a path separator'
% path)
# Expand symlinks
real_base_path = os.path.realpath(base_path)
real_file_path = os.path.realpath(file_path)
real_path = os.path.realpath(path)
# Use dir of file for prefix comparision, so we don't accept
# /tmp/foo.m3u as being inside /tmp/foo, simply because they have a
# common prefix, /tmp/foo, which matches the base path, /tmp/foo.
real_dir_path = os.path.dirname(real_file_path)
if os.path.isfile(path):
# Use dir of file for prefix comparision, so we don't accept
# /tmp/foo.m3u as being inside /tmp/foo, simply because they have a
# common prefix, /tmp/foo, which matches the base path, /tmp/foo.
real_path = os.path.dirname(real_path)
# Check if dir of file is the base path or a subdir
common_prefix = os.path.commonprefix([real_base_path, real_dir_path])
assert common_prefix == real_base_path, (
'File path %s must be in %s' % (real_file_path, real_base_path))
common_prefix = os.path.commonprefix([real_base_path, real_path])
return common_prefix == real_base_path
# FIXME replace with mock usage in tests.

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 gobject
import pykka
logger = logging.getLogger(__name__)
def send_async(cls, event, **kwargs):
# This file is imported by mopidy.backends, which again is imported by all
# backend extensions. By importing modules that are not easily installable
# close to their use, we make some extensions able to run their tests in a
# virtualenv with global site-packages disabled.
import gobject
gobject.idle_add(lambda: send(cls, event, **kwargs))

View File

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

View File

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

View File

@ -7,5 +7,5 @@ from mopidy.local import translator
class LocalPlaybackProvider(backend.PlaybackProvider):
def translate_uri(self, uri):
return translator.local_track_uri_to_file_uri(
return translator.local_uri_to_file_uri(
uri, self.backend.config['local']['media_dir'])

View File

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

View File

@ -5,25 +5,41 @@ import os
import urllib
from mopidy import compat
from mopidy.utils.path import path_to_uri, uri_to_path
from mopidy.internal import path
logger = logging.getLogger(__name__)
def local_track_uri_to_file_uri(uri, media_dir):
return path_to_uri(local_track_uri_to_path(uri, media_dir))
def local_uri_to_file_uri(uri, media_dir):
"""Convert local track or directory URI to file URI."""
return path_to_file_uri(local_uri_to_path(uri, media_dir))
def local_track_uri_to_path(uri, media_dir):
if not uri.startswith('local:track:'):
def local_uri_to_path(uri, media_dir):
"""Convert local track or directory URI to absolute path."""
if (
not uri.startswith('local:directory:') and
not uri.startswith('local:track:')):
raise ValueError('Invalid URI.')
file_path = uri_to_path(uri).split(b':', 1)[1]
file_path = path.uri_to_path(uri).split(b':', 1)[1]
return os.path.join(media_dir, file_path)
def local_track_uri_to_path(uri, media_dir):
# Deprecated version to keep old versions of Mopidy-Local-Sqlite working.
return local_uri_to_path(uri, media_dir)
def path_to_file_uri(abspath):
"""Convert absolute path to file URI."""
# Re-export internal method for use by Mopidy-Local-* extensions.
return path.path_to_uri(abspath)
def path_to_local_track_uri(relpath):
"""Convert path relative to media_dir to local track URI."""
"""Convert path relative to :confval:`local/media_dir` to local track
URI."""
if isinstance(relpath, compat.text_type):
relpath = relpath.encode('utf-8')
return b'local:track:%s' % urllib.quote(relpath)

View File

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

View File

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

View File

@ -1,5 +1,6 @@
from __future__ import absolute_import, unicode_literals
import codecs
import logging
import os
import re
@ -7,9 +8,8 @@ import urllib
import urlparse
from mopidy import compat
from mopidy.internal import encoding, path
from mopidy.models import Track
from mopidy.utils.encoding import locale_decode
from mopidy.utils.path import path_to_uri, uri_to_path
M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)')
@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
def playlist_uri_to_path(uri, playlists_dir):
if not uri.startswith('m3u:'):
raise ValueError('Invalid URI %s' % uri)
file_path = uri_to_path(uri)
file_path = path.uri_to_path(uri)
return os.path.join(playlists_dir, file_path)
@ -80,7 +80,7 @@ def parse_m3u(file_path, media_dir=None):
with open(file_path) as m3u:
contents = m3u.readlines()
except IOError as error:
logger.warning('Couldn\'t open m3u: %s', locale_decode(error))
logger.warning('Couldn\'t open m3u: %s', encoding.locale_decode(error))
return tracks
if not contents:
@ -98,13 +98,28 @@ def parse_m3u(file_path, media_dir=None):
continue
if urlparse.urlsplit(line).scheme:
tracks.append(track.copy(uri=line))
tracks.append(track.replace(uri=line))
elif os.path.normpath(line) == os.path.abspath(line):
path = path_to_uri(line)
tracks.append(track.copy(uri=path))
uri = path.path_to_uri(line)
tracks.append(track.replace(uri=uri))
elif media_dir is not None:
path = path_to_uri(os.path.join(media_dir, line))
tracks.append(track.copy(uri=path))
uri = path.path_to_uri(os.path.join(media_dir, line))
tracks.append(track.replace(uri=uri))
track = Track()
return tracks
def save_m3u(filename, tracks, encoding='latin1', errors='replace'):
extended = any(track.name for track in tracks)
# codecs.open() always uses binary mode, just being explicit here
with codecs.open(filename, 'wb', encoding, errors) as m3u:
if extended:
m3u.write('#EXTM3U' + os.linesep)
for track in tracks:
if extended and track.name:
m3u.write('#EXTINF:%d,%s%s' % (
track.length // 1000 if track.length else -1,
track.name,
os.linesep))
m3u.write(track.uri + os.linesep)

View File

@ -110,6 +110,10 @@ class Mixer(object):
logger.debug('Mixer event: mute_changed(mute=%s)', mute)
MixerListener.send('mute_changed', mute=mute)
def ping(self):
"""Called to check if the actor is still alive."""
return True
class MixerListener(listener.Listener):

View File

@ -1,151 +1,16 @@
from __future__ import absolute_import, unicode_literals
import json
from mopidy.models import fields
from mopidy.models.immutable import ImmutableObject, ValidatedImmutableObject
from mopidy.models.serialize import ModelJSONEncoder, model_json_decoder
__all__ = [
'ImmutableObject', 'Ref', 'Image', 'Artist', 'Album', 'track', 'TlTrack',
'Playlist', 'SearchResult', 'model_json_decoder', 'ModelJSONEncoder',
'ValidatedImmutableObject']
class ImmutableObject(object):
"""
Superclass for immutable objects whose fields can only be modified via the
constructor.
:param kwargs: kwargs to set as fields on the object
:type kwargs: any
"""
def __init__(self, *args, **kwargs):
for key, value in kwargs.items():
if not hasattr(self, key) or callable(getattr(self, key)):
raise TypeError(
'__init__() got an unexpected keyword argument "%s"' %
key)
if value == getattr(self, key):
continue # Don't explicitly set default values
self.__dict__[key] = value
def __setattr__(self, name, value):
if name.startswith('_'):
return super(ImmutableObject, self).__setattr__(name, value)
raise AttributeError('Object is immutable.')
def __repr__(self):
kwarg_pairs = []
for (key, value) in sorted(self.__dict__.items()):
if isinstance(value, (frozenset, tuple)):
if not value:
continue
value = list(value)
kwarg_pairs.append('%s=%s' % (key, repr(value)))
return '%(classname)s(%(kwargs)s)' % {
'classname': self.__class__.__name__,
'kwargs': ', '.join(kwarg_pairs),
}
def __hash__(self):
hash_sum = 0
for key, value in self.__dict__.items():
hash_sum += hash(key) + hash(value)
return hash_sum
def __eq__(self, other):
if not isinstance(other, self.__class__):
return False
return self.__dict__ == other.__dict__
def __ne__(self, other):
return not self.__eq__(other)
def copy(self, **values):
"""
Copy the model with ``field`` updated to new value.
Examples::
# Returns a track with a new name
Track(name='foo').copy(name='bar')
# Return an album with a new number of tracks
Album(num_tracks=2).copy(num_tracks=5)
:param values: the model fields to modify
:type values: dict
:rtype: new instance of the model being copied
"""
data = {}
for key in self.__dict__.keys():
public_key = key.lstrip('_')
value = values.pop(public_key, self.__dict__[key])
data[public_key] = value
for key in values.keys():
if hasattr(self, key):
value = values.pop(key)
data[key] = value
if values:
raise TypeError(
'copy() got an unexpected keyword argument "%s"' % key)
return self.__class__(**data)
def serialize(self):
data = {}
data['__model__'] = self.__class__.__name__
for key in self.__dict__.keys():
public_key = key.lstrip('_')
value = self.__dict__[key]
if isinstance(value, (set, frozenset, list, tuple)):
value = [
v.serialize() if isinstance(v, ImmutableObject) else v
for v in value]
elif isinstance(value, ImmutableObject):
value = value.serialize()
if not (isinstance(value, list) and len(value) == 0):
data[public_key] = value
return data
class ModelJSONEncoder(json.JSONEncoder):
"""
Automatically serialize Mopidy models to JSON.
Usage::
>>> import json
>>> json.dumps({'a_track': Track(name='name')}, cls=ModelJSONEncoder)
'{"a_track": {"__model__": "Track", "name": "name"}}'
"""
def default(self, obj):
if isinstance(obj, ImmutableObject):
return obj.serialize()
return json.JSONEncoder.default(self, obj)
def model_json_decoder(dct):
"""
Automatically deserialize Mopidy models from JSON.
Usage::
>>> import json
>>> json.loads(
... '{"a_track": {"__model__": "Track", "name": "name"}}',
... object_hook=model_json_decoder)
{u'a_track': Track(artists=[], name=u'name')}
"""
if '__model__' in dct:
model_name = dct.pop('__model__')
cls = globals().get(model_name, None)
if issubclass(cls, ImmutableObject):
kwargs = {}
for key, value in dct.items():
kwargs[key] = value
return cls(**kwargs)
return dct
class Ref(ImmutableObject):
class Ref(ValidatedImmutableObject):
"""
Model to represent URI references with a human friendly name and type
@ -161,14 +26,15 @@ class Ref(ImmutableObject):
"""
#: The object URI. Read-only.
uri = None
uri = fields.URI()
#: The object name. Read-only.
name = None
name = fields.String()
#: The object type, e.g. "artist", "album", "track", "playlist",
#: "directory". Read-only.
type = None
type = fields.Identifier() # TODO: consider locking this down.
# type = fields.Field(choices=(ALBUM, ARTIST, DIRECTORY, PLAYLIST, TRACK))
#: Constant used for comparison with the :attr:`type` field.
ALBUM = 'album'
@ -216,7 +82,7 @@ class Ref(ImmutableObject):
return cls(**kwargs)
class Image(ImmutableObject):
class Image(ValidatedImmutableObject):
"""
:param string uri: URI of the image
@ -225,16 +91,16 @@ class Image(ImmutableObject):
"""
#: The image URI. Read-only.
uri = None
uri = fields.URI()
#: Optional width of the image or :class:`None`. Read-only.
width = None
width = fields.Integer(min=0)
#: Optional height of the image or :class:`None`. Read-only.
height = None
height = fields.Integer(min=0)
class Artist(ImmutableObject):
class Artist(ValidatedImmutableObject):
"""
:param uri: artist URI
@ -246,16 +112,16 @@ class Artist(ImmutableObject):
"""
#: The artist URI. Read-only.
uri = None
uri = fields.URI()
#: The artist name. Read-only.
name = None
name = fields.String()
#: The MusicBrainz ID of the artist. Read-only.
musicbrainz_id = None
musicbrainz_id = fields.Identifier()
class Album(ImmutableObject):
class Album(ValidatedImmutableObject):
"""
:param uri: album URI
@ -277,39 +143,34 @@ class Album(ImmutableObject):
"""
#: The album URI. Read-only.
uri = None
uri = fields.URI()
#: The album name. Read-only.
name = None
name = fields.String()
#: A set of album artists. Read-only.
artists = frozenset()
artists = fields.Collection(type=Artist, container=frozenset)
#: The number of tracks in the album. Read-only.
num_tracks = None
num_tracks = fields.Integer(min=0)
#: The number of discs in the album. Read-only.
num_discs = None
num_discs = fields.Integer(min=0)
#: The album release date. Read-only.
date = None
date = fields.Date()
#: The MusicBrainz ID of the album. Read-only.
musicbrainz_id = None
musicbrainz_id = fields.Identifier()
#: The album image URIs. Read-only.
images = frozenset()
images = fields.Collection(type=basestring, container=frozenset)
# XXX If we want to keep the order of images we shouldn't use frozenset()
# as it doesn't preserve order. I'm deferring this issue until we got
# actual usage of this field with more than one image.
def __init__(self, *args, **kwargs):
self.__dict__['artists'] = frozenset(kwargs.pop('artists', None) or [])
self.__dict__['images'] = frozenset(kwargs.pop('images', None) or [])
super(Album, self).__init__(*args, **kwargs)
class Track(ImmutableObject):
class Track(ValidatedImmutableObject):
"""
:param uri: track URI
@ -345,64 +206,55 @@ class Track(ImmutableObject):
"""
#: The track URI. Read-only.
uri = None
uri = fields.URI()
#: The track name. Read-only.
name = None
name = fields.String()
#: A set of track artists. Read-only.
artists = frozenset()
artists = fields.Collection(type=Artist, container=frozenset)
#: The track :class:`Album`. Read-only.
album = None
album = fields.Field(type=Album)
#: A set of track composers. Read-only.
composers = frozenset()
composers = fields.Collection(type=Artist, container=frozenset)
#: A set of track performers`. Read-only.
performers = frozenset()
performers = fields.Collection(type=Artist, container=frozenset)
#: The track genre. Read-only.
genre = None
genre = fields.String()
#: The track number in the album. Read-only.
track_no = None
track_no = fields.Integer(min=0)
#: The disc number in the album. Read-only.
disc_no = None
disc_no = fields.Integer(min=0)
#: The track release date. Read-only.
date = None
date = fields.Date()
#: The track length in milliseconds. Read-only.
length = None
length = fields.Integer(min=0)
#: The track's bitrate in kbit/s. Read-only.
bitrate = None
bitrate = fields.Integer(min=0)
#: The track comment. Read-only.
comment = None
comment = fields.String()
#: The MusicBrainz ID of the track. Read-only.
musicbrainz_id = None
musicbrainz_id = fields.Identifier()
#: Integer representing when the track was last modified. Exact meaning
#: depends on source of track. For local files this is the modification
#: time in milliseconds since Unix epoch. For other backends it could be an
#: equivalent timestamp or simply a version counter.
last_modified = None
def __init__(self, *args, **kwargs):
def get(key):
return frozenset(kwargs.pop(key, None) or [])
self.__dict__['artists'] = get('artists')
self.__dict__['composers'] = get('composers')
self.__dict__['performers'] = get('performers')
super(Track, self).__init__(*args, **kwargs)
last_modified = fields.Integer(min=0)
class TlTrack(ImmutableObject):
class TlTrack(ValidatedImmutableObject):
"""
A tracklist track. Wraps a regular track and it's tracklist ID.
@ -425,10 +277,10 @@ class TlTrack(ImmutableObject):
"""
#: The tracklist ID. Read-only.
tlid = None
tlid = fields.Integer(min=0)
#: The track. Read-only.
track = None
track = fields.Field(type=Track)
def __init__(self, *args, **kwargs):
if len(args) == 2 and len(kwargs) == 0:
@ -441,7 +293,7 @@ class TlTrack(ImmutableObject):
return iter([self.tlid, self.track])
class Playlist(ImmutableObject):
class Playlist(ValidatedImmutableObject):
"""
:param uri: playlist URI
@ -456,23 +308,19 @@ class Playlist(ImmutableObject):
"""
#: The playlist URI. Read-only.
uri = None
uri = fields.URI()
#: The playlist name. Read-only.
name = None
name = fields.String()
#: The playlist's tracks. Read-only.
tracks = tuple()
tracks = fields.Collection(type=Track, container=tuple)
#: The playlist modification time in milliseconds since Unix epoch.
#: Read-only.
#:
#: Integer, or :class:`None` if unknown.
last_modified = None
def __init__(self, *args, **kwargs):
self.__dict__['tracks'] = tuple(kwargs.pop('tracks', None) or [])
super(Playlist, self).__init__(*args, **kwargs)
last_modified = fields.Integer(min=0)
# TODO: def insert(self, pos, track): ... ?
@ -482,7 +330,7 @@ class Playlist(ImmutableObject):
return len(self.tracks)
class SearchResult(ImmutableObject):
class SearchResult(ValidatedImmutableObject):
"""
:param uri: search result URI
@ -496,19 +344,13 @@ class SearchResult(ImmutableObject):
"""
# The search result URI. Read-only.
uri = None
uri = fields.URI()
# The tracks matching the search query. Read-only.
tracks = tuple()
tracks = fields.Collection(type=Track, container=tuple)
# The artists matching the search query. Read-only.
artists = tuple()
artists = fields.Collection(type=Artist, container=tuple)
# The albums matching the search query. Read-only.
albums = tuple()
def __init__(self, *args, **kwargs):
self.__dict__['tracks'] = tuple(kwargs.pop('tracks', None) or [])
self.__dict__['artists'] = tuple(kwargs.pop('artists', None) or [])
self.__dict__['albums'] = tuple(kwargs.pop('albums', None) or [])
super(SearchResult, self).__init__(*args, **kwargs)
albums = fields.Collection(type=Album, container=tuple)

154
mopidy/models/fields.py Normal file
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.core import CoreListener
from mopidy.internal import encoding, network, process
from mopidy.mpd import session, uri_mapper
from mopidy.utils import encoding, network, process
logger = logging.getLogger(__name__)

View File

@ -167,7 +167,8 @@ class MpdDispatcher(object):
# TODO: check that blacklist items are valid commands?
blacklist = self.config['mpd'].get('command_blacklist', [])
if tokens and tokens[0] in blacklist:
logger.warning('Client sent us blacklisted command: %s', tokens[0])
logger.warning(
'MPD client used blacklisted command: %s', tokens[0])
raise exceptions.MpdDisabled(command=tokens[0])
try:
return protocol.commands.call(tokens, context=self.context)

View File

@ -22,8 +22,8 @@ ENCODING = 'UTF-8'
#: The MPD protocol uses ``\n`` as line terminator.
LINE_TERMINATOR = '\n'
#: The MPD protocol version is 0.17.0.
VERSION = '0.17.0'
#: The MPD protocol version is 0.19.0.
VERSION = '0.19.0'
def load_protocol_modules():
@ -33,7 +33,8 @@ def load_protocol_modules():
"""
from . import ( # noqa
audio_output, channels, command_list, connection, current_playlist,
music_db, playback, reflection, status, stickers, stored_playlists)
mount, music_db, playback, reflection, status, stickers,
stored_playlists)
def INT(value): # noqa: N802

View File

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

View File

@ -1,7 +1,9 @@
from __future__ import absolute_import, unicode_literals
import urlparse
from mopidy.internal import deprecation
from mopidy.mpd import exceptions, protocol, translator
from mopidy.utils import deprecation
@protocol.commands.add('add')
@ -21,8 +23,11 @@ def add(context, uri):
if not uri.strip('/'):
return
if context.core.tracklist.add(uris=[uri]).get():
return
# If we have an URI just try and add it directly without bothering with
# jumping through browse...
if urlparse.urlparse(uri).scheme != '':
if context.core.tracklist.add(uris=[uri]).get():
return
try:
uris = []
@ -59,17 +64,21 @@ def addid(context, uri, songpos=None):
"""
if not uri:
raise exceptions.MpdNoExistError('No such song')
if songpos is not None and songpos > context.core.tracklist.length.get():
length = context.core.tracklist.get_length()
if songpos is not None and songpos > length.get():
raise exceptions.MpdArgError('Bad song index')
tl_tracks = context.core.tracklist.add(
uris=[uri], at_position=songpos).get()
if not tl_tracks:
raise exceptions.MpdNoExistError('No such song')
return ('Id', tl_tracks[0].tlid)
@protocol.commands.add('delete', position=protocol.RANGE)
def delete(context, position):
@protocol.commands.add('delete', songrange=protocol.RANGE)
def delete(context, songrange):
"""
*musicpd.org, current playlist section:*
@ -77,15 +86,15 @@ def delete(context, position):
Deletes a song from the playlist.
"""
start = position.start
end = position.stop
start = songrange.start
end = songrange.stop
if end is None:
end = context.core.tracklist.length.get()
end = context.core.tracklist.get_length().get()
tl_tracks = context.core.tracklist.slice(start, end).get()
if not tl_tracks:
raise exceptions.MpdArgError('Bad song index', command='delete')
for (tlid, _) in tl_tracks:
context.core.tracklist.remove(tlid=[tlid])
context.core.tracklist.remove({'tlid': [tlid]})
@protocol.commands.add('deleteid', tlid=protocol.UINT)
@ -97,7 +106,7 @@ def deleteid(context, tlid):
Deletes the song ``SONGID`` from the playlist
"""
tl_tracks = context.core.tracklist.remove(tlid=[tlid]).get()
tl_tracks = context.core.tracklist.remove({'tlid': [tlid]}).get()
if not tl_tracks:
raise exceptions.MpdNoExistError('No such song')
@ -114,8 +123,8 @@ def clear(context):
context.core.tracklist.clear()
@protocol.commands.add('move', position=protocol.RANGE, to=protocol.UINT)
def move_range(context, position, to):
@protocol.commands.add('move', songrange=protocol.RANGE, to=protocol.UINT)
def move_range(context, songrange, to):
"""
*musicpd.org, current playlist section:*
@ -124,10 +133,10 @@ def move_range(context, position, to):
Moves the song at ``FROM`` or range of songs at ``START:END`` to
``TO`` in the playlist.
"""
start = position.start
end = position.stop
start = songrange.start
end = songrange.stop
if end is None:
end = context.core.tracklist.length.get()
end = context.core.tracklist.get_length().get()
context.core.tracklist.move(start, end, to)
@ -142,7 +151,7 @@ def moveid(context, tlid, to):
the playlist. If ``TO`` is negative, it is relative to the current
song in the playlist (if there is one).
"""
tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
tl_tracks = context.core.tracklist.filter({'tlid': [tlid]}).get()
if not tl_tracks:
raise exceptions.MpdNoExistError('No such song')
position = context.core.tracklist.index(tl_tracks[0]).get()
@ -174,13 +183,9 @@ def playlistfind(context, tag, needle):
``playlistfind {TAG} {NEEDLE}``
Finds songs in the current playlist with strict matching.
*GMPC:*
- does not add quotes around the tag.
"""
if tag == 'filename':
tl_tracks = context.core.tracklist.filter(uri=[needle]).get()
tl_tracks = context.core.tracklist.filter({'uri': [needle]}).get()
if not tl_tracks:
return None
position = context.core.tracklist.index(tl_tracks[0]).get()
@ -199,14 +204,14 @@ def playlistid(context, tlid=None):
and specifies a single song to display info for.
"""
if tlid is not None:
tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
tl_tracks = context.core.tracklist.filter({'tlid': [tlid]}).get()
if not tl_tracks:
raise exceptions.MpdNoExistError('No such song')
position = context.core.tracklist.index(tl_tracks[0]).get()
return translator.track_to_mpd_format(tl_tracks[0], position=position)
else:
return translator.tracks_to_mpd_format(
context.core.tracklist.tl_tracks.get())
context.core.tracklist.get_tl_tracks().get())
@protocol.commands.add('playlistinfo')
@ -231,7 +236,7 @@ def playlistinfo(context, parameter=None):
tracklist_slice = protocol.RANGE(parameter)
start, end = tracklist_slice.start, tracklist_slice.stop
tl_tracks = context.core.tracklist.tl_tracks.get()
tl_tracks = context.core.tracklist.get_tl_tracks().get()
if start and start > len(tl_tracks):
raise exceptions.MpdArgError('Bad song index')
if end and end > len(tl_tracks):
@ -251,7 +256,6 @@ def playlistsearch(context, tag, needle):
*GMPC:*
- does not add quotes around the tag
- uses ``filename`` and ``any`` as tags
"""
raise exceptions.MpdNotImplemented # TODO
@ -274,10 +278,10 @@ def plchanges(context, version):
- Calls ``plchanges "-1"`` two times per second to get the entire playlist.
"""
# XXX Naive implementation that returns all tracks as changed
tracklist_version = context.core.tracklist.version.get()
tracklist_version = context.core.tracklist.get_version().get()
if version < tracklist_version:
return translator.tracks_to_mpd_format(
context.core.tracklist.tl_tracks.get())
context.core.tracklist.get_tl_tracks().get())
elif version == tracklist_version:
# A version match could indicate this is just a metadata update, so
# check for a stream ref and let the client know about the change.
@ -285,7 +289,7 @@ def plchanges(context, version):
if stream_title is None:
return None
tl_track = context.core.playback.current_tl_track.get()
tl_track = context.core.playback.get_current_tl_track().get()
position = context.core.tracklist.index(tl_track).get()
return translator.track_to_mpd_format(
tl_track, position=position, stream_title=stream_title)
@ -306,17 +310,65 @@ def plchangesposid(context, version):
``playlistlength`` returned by status command.
"""
# XXX Naive implementation that returns all tracks as changed
if int(version) != context.core.tracklist.version.get():
if int(version) != context.core.tracklist.get_version().get():
result = []
for (position, (tlid, _)) in enumerate(
context.core.tracklist.tl_tracks.get()):
context.core.tracklist.get_tl_tracks().get()):
result.append(('cpos', position))
result.append(('Id', tlid))
return result
@protocol.commands.add('shuffle', position=protocol.RANGE)
def shuffle(context, position=None):
@protocol.commands.add(
'prio', priority=protocol.UINT, position=protocol.RANGE)
def prio(context, priority, position):
"""
*musicpd.org, current playlist section:*
``prio {PRIORITY} {START:END...}``
Set the priority of the specified songs. A higher priority means that
it will be played first when "random" mode is enabled.
A priority is an integer between 0 and 255. The default priority of new
songs is 0.
"""
raise exceptions.MpdNotImplemented # TODO
@protocol.commands.add('prioid')
def prioid(context, *args):
"""
*musicpd.org, current playlist section:*
``prioid {PRIORITY} {ID...}``
Same as prio, but address the songs with their id.
"""
raise exceptions.MpdNotImplemented # TODO
@protocol.commands.add('rangeid', tlid=protocol.UINT, songrange=protocol.RANGE)
def rangeid(context, tlid, songrange):
"""
*musicpd.org, current playlist section:*
``rangeid {ID} {START:END}``
Specifies the portion of the song that shall be played. START and END
are offsets in seconds (fractional seconds allowed); both are optional.
Omitting both (i.e. sending just ":") means "remove the range, play
everything". A song that is currently playing cannot be manipulated
this way.
.. versionadded:: 0.19
New in MPD protocol version 0.19
"""
raise exceptions.MpdNotImplemented # TODO
@protocol.commands.add('shuffle', songrange=protocol.RANGE)
def shuffle(context, songrange=None):
"""
*musicpd.org, current playlist section:*
@ -325,10 +377,10 @@ def shuffle(context, position=None):
Shuffles the current playlist. ``START:END`` is optional and
specifies a range of songs.
"""
if position is None:
if songrange is None:
start, end = None, None
else:
start, end = position.start, position.stop
start, end = songrange.start, songrange.stop
context.core.tracklist.shuffle(start, end)
@ -341,7 +393,7 @@ def swap(context, songpos1, songpos2):
Swaps the positions of ``SONG1`` and ``SONG2``.
"""
tracks = context.core.tracklist.tracks.get()
tracks = context.core.tracklist.get_tracks().get()
song1 = tracks[songpos1]
song2 = tracks[songpos2]
del tracks[songpos1]
@ -365,8 +417,8 @@ def swapid(context, tlid1, tlid2):
Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids).
"""
tl_tracks1 = context.core.tracklist.filter(tlid=[tlid1]).get()
tl_tracks2 = context.core.tracklist.filter(tlid=[tlid2]).get()
tl_tracks1 = context.core.tracklist.filter({'tlid': [tlid1]}).get()
tl_tracks2 = context.core.tracklist.filter({'tlid': [tlid2]}).get()
if not tl_tracks1 or not tl_tracks2:
raise exceptions.MpdNoExistError('No such song')
position1 = context.core.tracklist.index(tl_tracks1[0]).get()
@ -374,39 +426,7 @@ def swapid(context, tlid1, tlid2):
swap(context, position1, position2)
# TODO: add at least reflection tests before adding NotImplemented version
# @protocol.commands.add(
# 'prio', priority=protocol.UINT, position=protocol.RANGE)
def prio(context, priority, position):
"""
*musicpd.org, current playlist section:*
``prio {PRIORITY} {START:END...}``
Set the priority of the specified songs. A higher priority means that
it will be played first when "random" mode is enabled.
A priority is an integer between 0 and 255. The default priority of new
songs is 0.
"""
pass
# TODO: add at least reflection tests before adding NotImplemented version
# @protocol.commands.add('prioid')
def prioid(context, *args):
"""
*musicpd.org, current playlist section:*
``prioid {PRIORITY} {ID...}``
Same as prio, but address the songs with their id.
"""
pass
# TODO: add at least reflection tests before adding NotImplemented version
# @protocol.commands.add('addtagid', tlid=protocol.UINT)
@protocol.commands.add('addtagid', tlid=protocol.UINT)
def addtagid(context, tlid, tag, value):
"""
*musicpd.org, current playlist section:*
@ -417,12 +437,14 @@ def addtagid(context, tlid, tag, value):
for remote songs. This change is volatile: it may be overwritten by
tags received from the server, and the data is gone when the song gets
removed from the queue.
.. versionadded:: 0.19
New in MPD protocol version 0.19
"""
pass
raise exceptions.MpdNotImplemented # TODO
# TODO: add at least reflection tests before adding NotImplemented version
# @protocol.commands.add('cleartagid', tlid=protocol.UINT)
@protocol.commands.add('cleartagid', tlid=protocol.UINT)
def cleartagid(context, tlid, tag):
"""
*musicpd.org, current playlist section:*
@ -432,5 +454,8 @@ def cleartagid(context, tlid, tag):
Removes tags from the specified song. If TAG is not specified, then all
tag values will be removed. Editing song tags is only possible for
remote songs.
.. versionadded:: 0.19
New in MPD protocol version 0.19
"""
pass
raise exceptions.MpdNotImplemented # TODO

Some files were not shown because too many files have changed in this diff Show More