Merge branch 'develop' into feature/implement-gapless

Conflicts:
	mopidy/backend.py
	mopidy/commands.py
	mopidy/core/actor.py
	mopidy/core/playback.py
	tests/audio/test_actor.py
	tests/core/test_playback.py
	tests/local/test_playback.py
This commit is contained in:
Thomas Adamcik 2015-04-05 19:00:40 +02:00
commit 11c9aa4ad0
190 changed files with 7263 additions and 3528 deletions

1
.gitignore vendored
View File

@ -16,3 +16,4 @@ docs/_build/
mopidy.log*
nosetests.xml
xunit-*.xml
tmp/

View File

@ -5,6 +5,9 @@ Kristian Klette <klette@samfundet.no>
Johannes Knutsen <johannes@knutseninfo.no> <johannes@iterate.no>
Johannes Knutsen <johannes@knutseninfo.no> <johannes@barbarmaclin.(none)>
John Bäckstrand <sopues@gmail.com> <sandos@XBMCLive.(none)>
David Caruso <deibido.caruso@gmail.com> <dav@dav.com>
Adam Rigg <adam@adamrigg.id.au> <radx@live.com.au>
Ernst Bammer <herr.ernst@gmail.com>
Alli Witheford <alzeih@gmail.com>
Alexandre Petitjean <alpetitjean@gmail.com>
Alexandre Petitjean <alpetitjean@gmail.com> <alexandre.petitjean@lne.fr>
@ -15,5 +18,9 @@ Janez Troha <janez.troha@gmail.com> <dz0ny@users.noreply.github.com>
Janez Troha <janez.troha@gmail.com> <dz0ny@ubuntu.si>
Luke Giuliani <luke@giuliani.com.au>
Colin Montgomerie <kiteflyingmonkey@gmail.com>
Nathan Harper <nathan.sam.harper@gmail.com>
Ignasi Fosch <natx@y10k.ws> <ifosch@serenity-2.local>
Christopher Schirner <christopher@hackerspace-bamberg.de> <schinken@hackerspace-bamberg.de>
Laura Barber <laura.c.barber@gmail.com> <artzii.laura@gmail.com>
John Cass <john.cass77@gmail.com>
Ronald Zielaznicki <zielaznickiz@g.cofc.edu>

View File

@ -1,8 +1,18 @@
sudo: false
language: python
python:
- "2.7_with_system_site_packages"
addons:
apt:
sources:
- mopidy-stable
packages:
- graphviz-dev
- mopidy
env:
- TOX_ENV=py27
- TOX_ENV=py27-tornado23
@ -11,10 +21,6 @@ env:
- TOX_ENV=flake8
install:
- "wget -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -"
- "sudo wget -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list"
- "sudo apt-get update || true"
- "sudo apt-get install mopidy graphviz-dev"
- "pip install tox"
script:
@ -23,6 +29,10 @@ script:
after_success:
- "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi"
branches:
except:
- debian
notifications:
irc:
channels:

14
AUTHORS
View File

@ -8,14 +8,14 @@
- John Bäckstrand <sopues@gmail.com>
- Fred Hatfull <fred.hatfull@gmail.com>
- Erling Børresen <erling@fenicore.net>
- David C <dav@dav.com>
- David Caruso <deibido.caruso@gmail.com>
- Christian Johansen <christian@cjohansen.no>
- Matt Bray <mattjbray@gmail.com>
- Trygve Aaberge <trygveaa@gmail.com>
- Wouter van Wijk <woutervanwijk@gmail.com>
- Jeremy B. Merrill <jeremybmerrill@gmail.com>
- 0xadam <radx@live.com.au>
- herrernst <herr.ernst@gmail.com>
- Adam Rigg <adam@adamrigg.id.au>
- Ernst Bammer <herr.ernst@gmail.com>
- Nick Steel <kingosticks@gmail.com>
- Zan Dobersek <zandobersek@gmail.com>
- Thomas Refis <refis.thomas@gmail.com>
@ -36,7 +36,7 @@
- Colin Montgomerie <kiteflyingmonkey@gmail.com>
- Simon de Bakker <simon@simbits.nl>
- Arnaud Barisain-Monrose <abarisain@gmail.com>
- nathanharper <nathan.sam.harper@gmail.com>
- Nathan Harper <nathan.sam.harper@gmail.com>
- Pierpaolo Frasa <pfrasa@smail.uni-koeln.de>
- Thomas Scholtes <thomas-scholtes@gmx.de>
- Sam Willcocks <sam@wlcx.cc>
@ -47,3 +47,9 @@
- Lukas Vogel <lukas@vogelnest.org>
- Thomas Amland <thomas.amland@gmail.com>
- Deni Bertovic <deni@kset.org>
- Ali Ukani <ali.ukani@gmail.com>
- Dirk Groenen <dirk_groenen@live.nl>
- John Cass <john.cass77@gmail.com>
- Laura Barber <laura.c.barber@gmail.com>
- Jakab Kristóf <jaksi07c8@gmail.com>
- Ronald Zielaznicki <zielaznickiz@g.cofc.edu>

View File

@ -12,12 +12,11 @@ flake8-import-order
mock
# Test runners
nose
pytest
pytest-cov
pytest-xdist
tox
# Measure test's code coverage
coverage
# Check that MANIFEST.in matches Git repo contents before making a release
check-manifest

View File

@ -22,15 +22,16 @@ Frontends
=========
Frontends expose Mopidy to the external world. They can implement servers for
protocols like MPD and MPRIS, and they can be used to update other services
when something happens in Mopidy, like the Last.fm scrobbler frontend does. See
:ref:`frontend-api` for more details.
protocols like HTTP, MPD and MPRIS, and they can be used to update other
services when something happens in Mopidy, like the Last.fm scrobbler frontend
does. See :ref:`frontend-api` for more details.
.. digraph:: frontend_architecture
"HTTP\nfrontend" -> Core
"MPD\nfrontend" -> Core
"MPRIS\nfrontend" -> Core
"Last.fm\nfrontend" -> Core
"Scrobbler\nfrontend" -> Core
Core
@ -55,6 +56,7 @@ See :ref:`core-api` for more details.
Core -> "Library\ncontroller"
Core -> "Playback\ncontroller"
Core -> "Playlists\ncontroller"
Core -> "History\ncontroller"
"Library\ncontroller" -> "Local backend"
"Library\ncontroller" -> "Spotify backend"
@ -95,7 +97,8 @@ Audio
The audio actor is a thin wrapper around the parts of the GStreamer library we
use. If you implement an advanced backend, you may need to implement your own
playback provider using the :ref:`audio-api`.
playback provider using the :ref:`audio-api`, but most backends can use the
default playback provider without any changes.
Mixer

View File

@ -64,6 +64,14 @@ Manages the music library, e.g. searching for tracks to be added to a playlist.
:members:
Mixer controller
================
Manages volume and muting.
.. autoclass:: mopidy.core.MixerController
:members:
Core listener
=============

View File

@ -14,18 +14,6 @@ WebSocket API for use both from browsers and Node.js. The
:ref:`http-explore-extension` extension, can also be used to get you
familiarized with HTTP based APIs.
.. warning:: API stability
Since the HTTP JSON-RPC API exposes our internal core API directly it is to
be regarded as **experimental**. We cannot promise to keep any form of
backwards compatibility between releases as we will need to change the core
API while working out how to support new use cases. Thus, if you use this
API, you must expect to do small adjustments to your client for every
release of Mopidy.
From Mopidy 1.0 and onwards, we intend to keep the core API far more
stable.
.. _http-post-api:

View File

@ -4,13 +4,10 @@
API reference
*************
.. warning:: API stability
.. note:: What is public?
Only APIs documented here are public and open for use by Mopidy
extensions. We will change these APIs, but will keep the changelog up to
date with all breaking changes.
From Mopidy 1.0 and onwards, we intend to keep these APIs far more stable.
extensions.
.. toctree::

View File

@ -8,18 +8,6 @@ We've made a JavaScript library, Mopidy.js, which wraps the
:ref:`websocket-api` and gets you quickly started with working on your client
instead of figuring out how to communicate with Mopidy.
.. warning:: API stability
Since the Mopidy.js API exposes our internal core API directly it is to be
regarded as **experimental**. We cannot promise to keep any form of
backwards compatibility between releases as we will need to change the core
API while working out how to support new use cases. Thus, if you use this
API, you must expect to do small adjustments to your client for every
release of Mopidy.
From Mopidy 1.0 and onwards, we intend to keep the core API far more
stable.
Getting the library for browser use
===================================
@ -289,9 +277,10 @@ unhandled errors. In general, unhandled errors will not go silently missing.
The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A
<http://wiki.commonjs.org/wiki/Promises/A>`_ standard. We use the
implementation known as `when.js <https://github.com/cujojs/when>`_. Please
refer to when.js' documentation or the standard for further details on how to
work with promise objects.
implementation known as `when.js <https://github.com/cujojs/when>`_, and
reexport it as ``Mopidy.when`` so you don't have to duplicate the dependency.
Please refer to when.js' documentation or the standard for further details on
how to work with promise objects.
Cleaning up

View File

@ -28,19 +28,54 @@ Data model relations
.. digraph:: model_relations
Playlist -> Track [ label="has 0..n" ]
Track -> Album [ label="has 0..1" ]
Track -> Artist [ label="has 0..n" ]
Album -> Artist [ label="has 0..n" ]
Ref -> Album [ style="dotted", weight=1 ]
Ref -> Artist [ style="dotted", weight=1 ]
Ref -> Directory [ style="dotted", weight=1 ]
Ref -> Playlist [ style="dotted", weight=1 ]
Ref -> Track [ style="dotted", weight=1 ]
SearchResult -> Artist [ label="has 0..n" ]
SearchResult -> Album [ label="has 0..n" ]
SearchResult -> Track [ label="has 0..n" ]
Playlist -> Track [ label="has 0..n", weight=2 ]
Track -> Album [ label="has 0..1", weight=10 ]
Track -> Artist [ label="has 0..n", weight=10 ]
Album -> Artist [ label="has 0..n", weight=10 ]
Image
SearchResult -> Artist [ label="has 0..n", weight=1 ]
SearchResult -> Album [ label="has 0..n", weight=1 ]
SearchResult -> Track [ label="has 0..n", weight=1 ]
TlTrack -> Track [ label="has 1", weight=20 ]
Data model API
==============
.. automodule:: mopidy.models
.. module:: mopidy.models
:synopsis: Data model API
:members:
.. autoclass:: mopidy.models.Ref
.. autoclass:: mopidy.models.Track
.. autoclass:: mopidy.models.Album
.. autoclass:: mopidy.models.Artist
.. autoclass:: mopidy.models.Playlist
.. autoclass:: mopidy.models.Image
.. autoclass:: mopidy.models.TlTrack
.. autoclass:: mopidy.models.SearchResult
Data model helpers
==================
.. autoclass:: mopidy.models.ImmutableObject
.. autoclass:: mopidy.models.ModelJSONEncoder
.. autofunction:: mopidy.models.model_json_decoder

View File

@ -14,7 +14,7 @@ our Git repository.
.. include:: ../AUTHORS
If you already enjoy Mopidy, or don't enjoy it and want to help us making
Mopidy better, the best way to do so is to contribute back to the community.
You can contribute code, documentation, tests, bug reports, or help other
users, spreading the word, etc. See :ref:`contributing` for a head start.
If want to help us making Mopidy better, the best way to do so is to contribute
back to the community, either through code, documentation, tests, bug reports,
or by helping other users, spreading the word, etc. See :ref:`contributing` for
a head start.

View File

@ -5,53 +5,424 @@ Changelog
This changelog is used to track all major changes to Mopidy.
v0.20.0 (UNRELEASED)
====================
v1.1.0 (UNRELEASED)
===================
**Core API**
Core API
--------
- Added :class:`mopidy.core.HistoryController` which keeps track of what
tracks have been played. (Fixes: :issue:`423`, PR: :issue:`803`)
- Calling :meth:`mopidy.core.library.LibraryController.search`` with ``kwargs``
as the query is no longer supported (PR: :issue:`1090`)
Internal changes
----------------
- Tests have been cleaned up to stop using deprecated APIs where feasible.
(Partial fix: :issue:`1083`, PR: :issue:`1090`)
v1.0.1 (UNRELEASED)
===================
- Audio: Software volume control has been reworked to greatly reduce the delay
between changing the volume and the change taking effect. (Fixes:
:issue:`1097`)
- Audio: As a side effect of the previous bug fix, software volume is no longer
tied to the PulseAudio application volume when using ``pulsesink``. This
behavior was confusing for many users and doesn't work well with the plans
for multiple outputs.
v1.0.0 (2015-03-25)
===================
Three months after our fifth anniversary, Mopidy 1.0 is finally here!
Since the release of 0.19, we've closed or merged approximately 140 issues and
pull requests through more than 600 commits by a record high 19 extraordinary
people, including seven newcomers. Thanks to :ref:`everyone <authors>` who has
:ref:`contributed <contributing>`!
For the longest time, the focus of Mopidy 1.0 was to be another incremental
improvement, to be numbered 0.20. The result is still very much an incremental
improvement, with lots of small and larger improvements across Mopidy's
functionality.
The major features of Mopidy 1.0 are:
- :ref:`Semantic Versioning <versioning>`. We promise to not break APIs before
Mopidy 2.0. A Mopidy extension working with Mopidy 1.0 should continue to
work with all Mopidy 1.x releases.
- Preparation work to ease migration to a cleaned up and leaner core API in
Mopidy 2.0, and to give us some of the benefits of the cleaned up core API
right away.
- Preparation work to enable gapless playback in an upcoming 1.x release.
Dependencies
------------
Since the previous release there are no changes to Mopidy's dependencies.
However, porting from GStreamer 0.10 to 1.x and support for running Mopidy with
Python 3.4+ is not far off on our roadmap.
Core API
--------
In the API used by all frontends and web extensions there is lots of methods
and arguments that are now deprecated in preparation for the next major
release. With the exception of some internals that leaked out in the playback
controller, no core APIs have been removed in this release. In other words,
most clients should continue to work unchanged when upgrading to Mopidy 1.0.
Though, it is strongly encouraged to review any use of the deprecated parts of
the API as those parts will be removed in Mopidy 2.0.
- **Deprecated:** Deprecate all Python properties in the core API. The
previously undocumented getter and setter methods are now the official API.
This aligns the Python API with the WebSocket/JavaScript API. Python
frontends needs to be updated. WebSocket/JavaScript API users are not
affected. (Fixes: :issue:`952`)
- Add :class:`mopidy.core.HistoryController` which keeps track of what tracks
have been played. (Fixes: :issue:`423`, :issue:`1056`, PR: :issue:`803`,
:issue:`1063`)
- Add :class:`mopidy.core.MixerController` which keeps track of volume and
mute. (Fixes: :issue:`962`)
Core library controller
~~~~~~~~~~~~~~~~~~~~~~~
- **Deprecated:** :meth:`mopidy.core.LibraryController.find_exact`. Use
:meth:`mopidy.core.LibraryController.search` with the ``exact`` keyword
argument set to :class:`True`.
- **Deprecated:** The ``uri`` argument to
:meth:`mopidy.core.LibraryController.lookup`. Use new ``uris`` keyword
argument instead.
- Add ``exact`` keyword argument to
:meth:`mopidy.core.LibraryController.search`.
- Add ``uris`` keyword argument to :meth:`mopidy.core.LibraryController.lookup`
which allows for simpler lookup of multiple URIs. (Fixes: :issue:`1008`, PR:
:issue:`1047`)
- Updated :meth:`mopidy.core.LibraryController.search` and
:meth:`mopidy.core.LibraryController.find_exact` to normalize and warn about
malformed queries from clients. (Fixes: :issue:`1067`, PR: :issue:`1073`)
- Add :meth:`mopidy.core.LibraryController.get_distinct` for getting unique
values for a given field. (Fixes: :issue:`913`, PR: :issue:`1022`)
- Add :meth:`mopidy.core.LibraryController.get_images` for looking up images
for any URI that is known to the backends. (Fixes :issue:`973`, PR:
:issue:`981`, :issue:`992` and :issue:`1013`)
Core playlist controller
~~~~~~~~~~~~~~~~~~~~~~~~
- **Deprecated:** :meth:`mopidy.core.PlaylistsController.get_playlists`. Use
:meth:`~mopidy.core.PlaylistsController.as_list` and
:meth:`~mopidy.core.PlaylistsController.get_items` instead. (Fixes:
:issue:`1057`, PR: :issue:`1075`)
- **Deprecated:** :meth:`mopidy.core.PlaylistsController.filter`. Use
:meth:`~mopidy.core.PlaylistsController.as_list` and filter yourself.
- Add :meth:`mopidy.core.PlaylistsController.as_list`. (Fixes: :issue:`1057`,
PR: :issue:`1075`)
- Add :meth:`mopidy.core.PlaylistsController.get_items`. (Fixes: :issue:`1057`,
PR: :issue:`1075`)
Core tracklist controller
~~~~~~~~~~~~~~~~~~~~~~~~~
- **Removed:** The following methods were documented as internal. They are now
fully private and unavailable outside the core actor. (Fixes: :issue:`1058`,
PR: :issue:`1062`)
- :meth:`mopidy.core.TracklistController.mark_played`
- :meth:`mopidy.core.TracklistController.mark_playing`
- :meth:`mopidy.core.TracklistController.mark_unplayable`
- Add ``uris`` argument to :meth:`mopidy.core.TracklistController.add` which
allows for simpler addition of multiple URIs to the tracklist. (Fixes:
:issue:`1060`, PR: :issue:`1065`)
Core playback controller
~~~~~~~~~~~~~~~~~~~~~~~~
- **Removed:** Remove several internal parts that were leaking into the public
API and was never intended to be used externally. (Fixes: :issue:`1070`, PR:
:issue:`1076`)
- :meth:`mopidy.core.PlaybackController.change_track` is now internal.
- Removed ``on_error_step`` keyword argument from
:meth:`mopidy.core.PlaybackController.play`
- Removed ``clear_current_track`` keyword argument to
:meth:`mopidy.core.Playback.stop`. It was a leaky internal abstraction,
which was never intended to be used externally.
:meth:`mopidy.core.PlaybackController.stop`.
**Commands**
- Made the following event triggers internal:
- :meth:`mopidy.core.PlaybackController.on_end_of_track`
- :meth:`mopidy.core.PlaybackController.on_stream_changed`
- :meth:`mopidy.core.PlaybackController.on_tracklist_changed`
- :meth:`mopidy.core.PlaybackController.set_current_tl_track` is now
internal.
- **Deprecated:** The old methods on :class:`mopidy.core.PlaybackController`
for volume and mute management have been deprecated. Use
:class:`mopidy.core.MixerController` instead. (Fixes: :issue:`962`)
- When seeking while paused, we no longer change to playing. (Fixes:
:issue:`939`, PR: :issue:`1018`)
- Changed :meth:`mopidy.core.PlaybackController.play` to take the return value
from :meth:`mopidy.backend.PlaybackProvider.change_track` into account when
determining the success of the :meth:`~mopidy.core.PlaybackController.play`
call. (PR: :issue:`1071`)
- Add :meth:`mopidy.core.Listener.stream_title_changed` and
:meth:`mopidy.core.PlaybackController.get_stream_title` for letting clients
know about the current title in streams. (PR: :issue:`938`, :issue:`1030`)
Backend API
-----------
In the API implemented by all backends there have been way fewer but somewhat
more drastic changes with some methods removed and new ones being required for
certain functionality to continue working. Most backends were already updated to
be compatible with Mopidy 1.0 before the release. New versions of the backends
will be released shortly after Mopidy itself.
Backend library providers
~~~~~~~~~~~~~~~~~~~~~~~~~
- **Removed:** Remove :meth:`mopidy.backend.LibraryProvider.find_exact`.
- Add an ``exact`` keyword argument to
:meth:`mopidy.backend.LibraryProvider.search` to replace the old
:meth:`~mopidy.backend.LibraryProvider.find_exact` method.
Backend playlist providers
~~~~~~~~~~~~~~~~~~~~~~~~~~
- **Removed:** Remove default implementation of
:attr:`mopidy.backend.PlaylistsProvider.playlists`. This is potentially
backwards incompatible. (PR: :issue:`1046`)
- Changed the API for :class:`mopidy.backend.PlaylistsProvider`. Note that this
change is **not** backwards compatible. These changes are important to reduce
the Mopidy startup time. (Fixes: :issue:`1057`, PR: :issue:`1075`)
- Add :meth:`mopidy.backend.PlaylistsProvider.as_list`.
- Add :meth:`mopidy.backend.PlaylistsProvider.get_items`.
- Remove :attr:`mopidy.backend.PlaylistsProvider.playlists` property.
Backend playback providers
~~~~~~~~~~~~~~~~~~~~~~~~~~
- Changed the API for :class:`mopidy.backend.PlaybackProvider`. Note that this
change is **not** backwards compatible for certain backends. These changes
are crucial to adding gapless in one of the upcoming releases.
(Fixes: :issue:`1052`, PR: :issue:`1064`)
- :meth:`mopidy.backend.PlaybackProvider.translate_uri` has been added. It is
strongly recommended that all backends migrate to using this API for
translating "Mopidy URIs" to real ones for playback.
- The semantics and signature of :meth:`mopidy.backend.PlaybackProvider.play`
has changed. The method is now only used to set the playback state to
playing, and no longer takes a track.
Backends must migrate to
:meth:`mopidy.backend.PlaybackProvider.translate_uri` or
:meth:`mopidy.backend.PlaybackProvider.change_track` to continue working.
- :meth:`mopidy.backend.PlaybackProvider.prepare_change` has been added.
Models
------
- Add :class:`mopidy.models.Image` model to be returned by
:meth:`mopidy.core.LibraryController.get_images`. (Part of :issue:`973`)
- Change the semantics of :attr:`mopidy.models.Track.last_modified` to be
milliseconds instead of seconds since Unix epoch, or a simple counter,
depending on the source of the track. This makes it match the semantics of
:attr:`mopidy.models.Playlist.last_modified`. (Fixes: :issue:`678`, PR:
:issue:`1036`)
Commands
--------
- Make the ``mopidy`` command print a friendly error message if the
:mod:`gobject` Python module cannot be imported. (Fixes: :issue:`836`)
**Local backend**
- Add support for repeating the :option:`-v <mopidy -v>` argument four times
to set the log level for all loggers to the lowest possible value, including
log records at levels lower than ``DEBUG`` too.
- Add cover URL to all scanned files with MusicBrainz album IDs. (Fixes:
:issue:`697`, PR: :issue:`802`)
- Add path to the current ``mopidy`` executable to the output of ``mopidy
deps``. This make it easier to see that a user is using pip-installed Mopidy
instead of APT-installed Mopidy without asking for ``which mopidy`` output.
- Local library API: Implementors of :meth:`mopidy.local.Library.lookup` should
now return a list of :class:`~mopidy.models.Track` instead of a single track,
just like the other ``lookup()`` methods in Mopidy. For now, returning a
single track will continue to work. (PR: :issue:`840`)
Configuration
-------------
**File scanner**
- Add support for the log level value ``all`` to the loglevels configurations.
This can be used to show absolutely all log records, including those at
custom levels below ``DEBUG``.
- Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`)
- Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`)
- Add symlink support with loop protection to file finder (Fixes: :issue:`858`,
PR: :isusue:`874`)
Logging
-------
**MPD frontend**
- Add custom log level ``TRACE`` (numerical level 5), which can be used by
Mopidy and extensions to log at an even more detailed level than ``DEBUG``.
- In stored playlist names, replace "/", which are illegal, with "|" instead of
a whitespace. Pipes are more similar to forward slash.
- Add support for per logger color overrides. (Fixes: :issue:`808`, PR:
:issue:`1005`)
Local backend
-------------
- Improve error logging for scanner. (Fixes: :issue:`856`, PR: :issue:`874`)
- Add symlink support with loop protection to file finder. (Fixes:
:issue:`858`, PR: :issue:`874`)
- Add ``--force`` option for ``mopidy local scan`` for forcing a full rescan of
the library. (Fixes: :issue:`910`, PR: :issue:`1010`)
- Stop ignoring ``offset`` and ``limit`` in searches when using the default
JSON backed local library. (Fixes: :issue:`917`, PR: :issue:`949`)
- Removed double triggering of ``playlists_loaded`` event.
(Fixes: :issue:`998`, PR: :issue:`999`)
- Cleanup and refactoring of local playlist code. Preserves playlist names
better and fixes bug in deletion of playlists. (Fixes: :issue:`937`,
PR: :issue:`995` and rebased into :issue:`1000`)
- Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`)
- Moved playlist support out to a new extension, :ref:`ext-m3u`.
- *Deprecated:* The config value :confval:`local/playlists_dir` is no longer in
use and can be removed from your config.
Local library API
~~~~~~~~~~~~~~~~~
- Implementors of :meth:`mopidy.local.Library.lookup` should now return a list
of :class:`~mopidy.models.Track` instead of a single track, just like the
other ``lookup()`` methods in Mopidy. For now, returning a single track will
continue to work. (PR: :issue:`840`)
- Add support for giving local libraries direct access to tags and duration.
(Fixes: :issue:`967`)
- Add :meth:`mopidy.local.Library.get_images` for looking up images
for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032` and :issue:`1037`)
Stream backend
--------------
- Add support for HTTP proxies when doing initial metadata lookup for a stream.
(Fixes :issue:`390`, PR: :issue:`982`)
- Add basic tests for the stream library provider.
M3U backend
-----------
- Mopidy-M3U is a new bundled backend. It provides the same M3U support as was
previously part of the local backend. See :ref:`m3u-migration` for how to
migrate your local playlists to work with the M3U backend. (Fixes:
:issue:`1054`, PR: :issue:`1066`)
- In playlist names, replace "/", which are illegal in M3U file names,
with "|". (PR: :issue:`1084`)
MPD frontend
------------
- Add support for blacklisting MPD commands. This is used to prevent clients
from using ``listall`` and ``listallinfo`` which recursively lookup the entire
"database". If you insist on using a client that needs these commands change
:confval:`mpd/command_blacklist`.
- Start setting the ``Name`` field with the stream title when listening to
radio streams. (Fixes: :issue:`944`, PR: :issue:`1030`)
- Enable browsing of artist references, in addition to albums and playlists.
(PR: :issue:`884`)
**Audio**
- Switch the ``list`` command over to using the new method
:meth:`mopidy.core.LibraryController.get_distinct` for increased performance.
(Fixes: :issue:`913`)
- Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`. Pass a
:class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end the
stream.
- In stored playlist names, replace "/", which are illegal, with "|" instead of
a whitespace. Pipes are more similar to forward slash.
- Share a single mapping between names and URIs across all MPD sessions. (Fixes:
:issue:`934`, PR: :issue:`968`)
- Add support for ``toggleoutput`` command. (PR: :issue:`1015`)
- The ``mixrampdb`` and ``mixrampdelay`` commands are now known to Mopidy, but
are not implemented. (PR: :issue:`1015`)
- Fix crash on socket error when using a locale causing the exception's error
message to contain characters not in ASCII. (Fixes: issue:`971`, PR:
:issue:`1044`)
HTTP frontend
-------------
- **Deprecated:** Deprecated the :confval:`http/static_dir` config. Please make
your web clients pip-installable Mopidy extensions to make it easier to
install for end users.
- Prevent a race condition in WebSocket event broadcasting from crashing the
web server. (PR: :issue:`1020`)
Mixers
------
- Add support for disabling volume control in Mopidy entirely by setting the
configuration :confval:`audio/mixer` to ``none``. (Fixes: :issue:`936`, PR:
:issue:`1015`, :issue:`1035`)
Audio
-----
- **Removed:** Support for visualizers and the :confval:`audio/visualizer`
config value. The feature was originally added as a workaround for all the
people asking for ncmpcpp visualizer support, and since we could get it
almost for free thanks to GStreamer. But, this feature did never make sense
for a server such as Mopidy.
- **Deprecated:** Deprecated :meth:`mopidy.audio.Audio.emit_end_of_stream`.
Pass a :class:`None` buffer to :meth:`mopidy.audio.Audio.emit_data` to end
the stream. This should only affect Mopidy-Spotify.
- Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new
tags are found.
- Add :meth:`mopidy.audio.Audio.get_current_tags` for looking up the current
tags of the playing media.
- Internal code cleanup within audio subsystem:
@ -65,40 +436,59 @@ v0.20.0 (UNRELEASED)
- Add internal helper for converting GStreamer data types to Python.
- Move MusicBrainz coverart code out of audio and into local.
- Reduce scope of audio scanner to just tags + duration. Mtime, uri and min
length handling are now outside of this class.
- Reduce scope of audio scanner to just find tags and duration. Modification
time, URI and minimum length handling are now outside of this class.
- Update scanner to operate with milliseconds for duration.
- Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new tags
are found.
- Update scanner to use a custom source, typefind and decodebin. This allows
us to detect playlists before we try to decode them.
- Add :meth:`mopidy.audio.Audio.get_current_tags` for looking up the current
tags of the playing media.
- Refactored scanner to create a new pipeline per track, this is needed as
reseting decodebin is much slower than tearing it down and making a fresh
one.
- Move and rename helper for converting tags to tracks.
- Helper now ignores albums without a name.
- Ignore albums without a name when converting tags to tracks.
- Kill support for visualizers. Feature was originally added as a workaround for
all the people asking for ncmpcpp visualizer support. And since we could get
it almost for free thanks to GStreamer. But this feature didn't really ever
make sense for a server such as Mopidy. Currently the only way to find out if
it is in use and will be missed is to go ahead and remove it.
- Support UTF-8 in M3U playlists. (Fixes: :issue:`853`)
**Stream backend**
- Add workaround for volume not persisting across tracks on OS X.
(Issue: :issue:`886`, PR: :issue:`958`)
- Add basic tests for the stream library provider.
- Improved missing plugin error reporting in scanner. (PR: :issue:`1033`)
- Introduced a new return type for the scanner, a named tuple with ``uri``,
``tags``, ``duration``, ``seekable`` and ``mime``. (PR: :issue:`1033`)
v0.19.6 (UNRELEASED)
====================
- Added support for checking if the media is seekable, and getting the initial
MIME type guess. (PR: :issue:`1033`)
Bug fix release.
Mopidy.js client library
------------------------
- Audio: Support UTF-8 in M3U playlists. (Fixes: :issue:`853`)
This version has been released to npm as Mopidy.js v0.5.0.
- Reexport When.js library as ``Mopidy.when``, to make it easily available to
users of Mopidy.js. (Fixes: :js:`1`)
- Default to ``wss://`` as the WebSocket protocol if the page is hosted on
``https://``. This has no effect if the ``webSocketUrl`` setting is
specified. (Pull request: :js:`2`)
- Upgrade dependencies.
Development
-----------
- Add new :ref:`contribution guidelines <contributing>`.
- Add new :ref:`development guide <devenv>`.
- Speed up event emitting.
- Changed test runner from nose to py.test. (PR: :issue:`1024`)
v0.19.5 (2014-12-23)
@ -571,6 +961,7 @@ guys. Thanks to everyone that has contributed!
- The dummy backend used for testing many frontends have moved from
:mod:`mopidy.backends.dummy` to :mod:`mopidy.backend.dummy`.
(PR: :issue:`984`)
**Commands**

View File

@ -37,15 +37,13 @@ There are two ways Mopidy can be made available as an UPnP MediaRenderer:
Using Mopidy-MPRIS and Rygel, or using Mopidy-MPD and upmpdcli.
.. _upmpdcli:
upmpdcli
--------
`upmpdcli <http://www.lesbonscomptes.com/upmpdcli/>`_ is recommended, since it
is easier to setup, and offers `OpenHome <http://www.openhome.org> ohMedia`_
compatibility. upmpdcli exposes a UPnP MediaRenderer to the network, while
using the MPD protocol to control Mopidy.
is easier to setup, and offers `OpenHome
<http://www.openhome.org/wiki/OhMedia>`_ compatibility. upmpdcli exposes a UPnP
MediaRenderer to the network, while using the MPD protocol to control Mopidy.
1. Install upmpdcli. On Debian/Ubuntu::
@ -68,8 +66,6 @@ using the MPD protocol to control Mopidy.
4. A UPnP renderer should be available now.
.. _rygel:
Rygel
-----

View File

@ -43,7 +43,7 @@ Options
.. cmdoption:: --verbose, -v
Show more output. Repeat up to 3 times for even more.
Show more output. Repeat up to four times for even more.
.. cmdoption:: --save-debug-log

View File

@ -15,6 +15,7 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../'))
class Mock(object):
def __init__(self, *args, **kwargs):
pass
@ -34,11 +35,13 @@ class Mock(object):
elif name == 'get_user_config_dir':
# glib.get_user_config_dir()
return str
elif (name[0] == name[0].upper()
elif (name[0] == name[0].upper() and
# gst.Caps
not name.startswith('Caps') and
# gst.PadTemplate
and not name.startswith('PadTemplate')
not name.startswith('PadTemplate') and
# dbus.String()
and not name == 'String'):
not name == 'String'):
return type(name, (), {})
else:
return Mock()
@ -112,6 +115,9 @@ modindex_common_prefix = ['mopidy.']
# -- Options for HTML output --------------------------------------------------
# 'sphinx_rtd_theme' is bundled with Sphinx 1.3, which we don't have when
# building the docs as part of the Debian packages on e.g. Debian wheezy.
# html_theme = 'sphinx_rtd_theme'
html_theme = 'default'
html_theme_path = ['_themes']
html_static_path = ['_static']
@ -155,6 +161,7 @@ man_pages = [
extlinks = {
'issue': ('https://github.com/mopidy/mopidy/issues/%s', '#'),
'commit': ('https://github.com/mopidy/mopidy/commit/%s', 'commit '),
'js': ('https://github.com/mopidy/mopidy.js/issues/%s', 'mopidy.js#'),
'mpris': (
'https://github.com/mopidy/mopidy-mpris/issues/%s', 'mopidy-mpris#'),
'discuss': ('https://discuss.mopidy.com/t/%s', 'discuss.mopidy.com/t/'),

View File

@ -70,6 +70,8 @@ Audio configuration
will affect the audio volume if you're streaming the audio from Mopidy
through Shoutcast.
If you want to disable audio mixing set the value to ``none``.
If you want to use a hardware mixer, you need to install a Mopidy extension
which integrates with your sound subsystem. E.g. for ALSA, install
`Mopidy-ALSAMixer <https://github.com/mopidy/mopidy-alsamixer>`_.
@ -131,6 +133,14 @@ Logging configuration
level to use for that logger, one of ``debug``, ``info``, ``warning``,
``error``, or ``critical``.
.. confval:: logcolors/*
The ``logcolors`` config section can be used to change the log color for
specific parts of Mopidy during development or debugging. Each key in the
config section should match the name of a logger. The value is the color
to use for that logger, one of ``black``, ``red``, ``green``, ``yellow``,
``blue``, ``magenta``, ``cyan`` or ``white``.
.. _the Python logging docs: http://docs.python.org/2/library/logging.config.html

View File

@ -4,128 +4,116 @@
Contributing
************
If you are thinking about making Mopidy better, or you just want to hack on it,
thats great. Here are some tips to get you started.
If you want to contribute to Mopidy, here are some tips to get you started.
Getting started
===============
.. _asking-questions:
#. Make sure you have a `GitHub account <https://github.com/signup/free>`_.
Asking questions
================
#. `Submit <https://github.com/mopidy/mopidy/issues/new>`_ a ticket for your
issue, assuming one does not already exist. Clearly describe the issue
including steps to reproduce when it is a bug.
Please get in touch with us in one of these ways when requesting help with
Mopidy and its extensions:
#. Fork the repository on GitHub.
- Our discussion forum: `discuss.mopidy.com <https://discuss.mopidy.com>`_.
Just sign in and fire away.
- Our IRC channel: `#mopidy <https://webchat.freenode.net/?channels=#mopidy>`_
on `irc.freenode.net <http://freenode.net>`_,
with public `searchable logs <https://botbot.me/freenode/mopidy/>`_. Be
prepared to hang around for a while, as we're not always around to answer
straight away.
Before asking for help, it might be worth your time to read the
:ref:`troubleshooting` page, both so you might find a solution to your problem
but also to be able to provide useful details when asking for help.
Making changes
==============
Helping users
=============
#. Clone your fork on GitHub to your computer.
#. Consider making a Python `virtualenv <http://www.virtualenv.org/>`_ for
Mopidy development to wall of Mopidy and it's dependencies from the rest of
your system. If you do so, create the virtualenv with the
``--system-site-packages`` flag so that Mopidy can use globally installed
dependencies like GStreamer. If you don't use a virtualenv, you may need to
run the following ``pip`` and ``python setup.py`` commands with ``sudo`` to
install stuff globally on your computer.
#. Install dependencies as described in the :ref:`installation` section.
#. Install additional development dependencies::
pip install -r dev-requirements.txt
#. Checkout a new branch (usually based on ``develop``) and name it accordingly
to what you intend to do.
- Features get the prefix ``feature/``
- Bug fixes get the prefix ``fix/``
- Improvements to the documentation get the prefix ``docs/``
If you want to contribute to Mopidy, a great place to start is by helping other
users on IRC and in the discussion forum. This is a contribution we value
highly. As more people help with user support, new users get faster and better
help. For your own benefit, you'll quickly learn what users find confusing,
difficult or lacking, giving you some ideas for where you may contribute
improvements, either to code or documentation. Lastly, this may also free up
time for other contributors to spend more time on fixing bugs or implementing
new features.
.. _run-from-git:
.. _issue-guidelines:
Running Mopidy from Git
Issue guidelines
================
#. If you need help, see :ref:`asking-questions` above. The GitHub issue
tracker is not a support forum.
#. If you are not sure if what you're experiencing is a bug or not, post in the
`discussion forum <https://discuss.mopidy.com>`__ first to verify that it's
a bug.
#. If you are sure that you've found a bug or have a feature request, check if
there's already an issue in the `issue tracker
<https://github.com/mopidy/mopidy/issues>`_. If there is, see if there is
anything you can add to help reproduce or fix the issue.
#. If there is no exising issue matching your bug or feature request, create a
`new issue <https://github.com/mopidy/mopidy/issues/new>`_. Please include
as much relevant information as possible. If it's a bug, including how to
reproduce the bug and any relevant logs or error messages.
Pull request guidelines
=======================
If you want to hack on Mopidy, you should run Mopidy directly from the Git
repo.
#. Before spending any time on making a pull request:
#. Go to the Git repo root::
- If it's a bug, :ref:`file an issue <issue-guidelines>`.
cd mopidy/
- If it's an enhancement, discuss it with other Mopidy developers first,
either in a GitHub issue, on the discussion forum, or on IRC. Making sure
your ideas and solutions are aligned with other contributors greatly
increases the odds of your pull request being quickly accepted.
#. To get a ``mopidy`` executable and register all bundled extensions with
setuptools, run::
#. Create a new branch, based on the ``develop`` branch, for every feature or
bug fix. Keep branches small and on topic, as that makes them far easier to
review. We often use the following naming convention for branches:
python setup.py develop
- Features get the prefix ``feature/``, e.g.
``feature/track-last-modified-as-ms``.
It still works to run ``python mopidy`` directly on the ``mopidy`` Python
package directory, but if you have never run ``python setup.py develop`` the
extensions bundled with Mopidy isn't registered with setuptools, so Mopidy
will start without any frontends or backends, making it quite useless.
- Bug fixes get the prefix ``fix/``, e.g. ``fix/902-consume-track-on-next``.
#. Now you can run the Mopidy command, and it will run using the code
in the Git repo::
- Improvements to the documentation get the prefix ``docs/``, e.g.
``docs/add-ext-mopidy-spotify-tunigo``.
mopidy
#. Follow the :ref:`code style <codestyle>`, especially make sure the
``flake8`` linter does not complain about anything. Travis CI will check
that your pull request is "flake8 clean". See :ref:`code-linting`.
If you do any changes to the code, you'll just need to restart ``mopidy``
to see the changes take effect.
#. Include tests for any new feature or substantial bug fix. See
:ref:`running-tests`.
#. Include documentation for any new feature. See :ref:`writing-docs`.
Testing
=======
#. Feel free to include a changelog entry in your pull request. The changelog
is in :file:`docs/changelog.rst`.
Mopidy has quite good test coverage, and we would like all new code going into
Mopidy to come with tests.
#. Write good commit messages.
#. To run all tests, go to the project directory and run::
- Follow the template "topic: description" for the first line of the commit
message, e.g. "mpd: Switch list command to using list_distinct". See the
commit history for inspiration.
nosetests
- Use the rest of the commit message to explain anything you feel isn't
obvious. It's better to have the details here than in the pull request
description, since the commit message will live forever.
To run tests with test coverage statistics::
- Write in the imperative, present tense: "add" not "added".
nosetests --with-coverage
Test coverage statistics can also be viewed online at
`coveralls.io <https://coveralls.io/r/mopidy/mopidy>`_.
#. Always check the code for errors and style issues using flake8::
flake8
If successful, the command will not print anything at all. Ignore the rare
cases you need to ignore a check use `# noqa: <code>` so we can lookup what
you are ignoring.
#. Finally, there is the ultimate but a bit slower command. To run both tests,
docs build, and flake8 linting, run::
tox
This will run exactly the same tests as `Travis CI
<https://travis-ci.org/mopidy/mopidy>`_ runs for all our branches and pull
requests. If this command turns green, you can be quite confident that your
pull request will get the green flag from Travis as well, which is a
requirement for it to be merged.
Submitting changes
==================
- One branch per feature or fix. Keep branches small and on topic.
- Follow the :ref:`code style <codestyle>`, especially make sure ``flake8``
does not complain about anything.
- Write good commit messages. Here's three blog posts on how to do it right:
For more inspiration, feel free to read these blog posts:
- `Writing Git commit messages
<http://365git.tumblr.com/post/3308646748/writing-git-commit-messages>`_
@ -136,17 +124,5 @@ Submitting changes
- `On commit messages
<http://who-t.blogspot.ch/2009/12/on-commit-messages.html>`_
- Send a pull request to the ``develop`` branch. See the `GitHub pull request
#. Send a pull request to the ``develop`` branch. See the `GitHub pull request
docs <https://help.github.com/articles/using-pull-requests>`_ for help.
Additional resources
====================
- IRC channel: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
- `Issue tracker <https://github.com/mopidy/mopidy/issues>`_
- `Mailing List <https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_
- `GitHub documentation <https://help.github.com/>`_

View File

@ -1,13 +1,20 @@
.. _debian:
**************
Debian package
**************
***************
Debian packages
***************
The Mopidy Debian package is available from `apt.mopidy.com
The Mopidy Debian package, ``mopidy``, is available from `apt.mopidy.com
<http://apt.mopidy.com/>`__ as well as from Debian, Ubuntu and other
Debian-based Linux distributions.
Some extensions are also available from all of these sources, while others,
like Mopidy-Spotify and its dependencies, are only available from
apt.mopidy.com. This may either be temporary until the package is uploaded to
Debian and with time propagates to the other distributions. It may also be more
long term, like in the Mopidy-Spotify case where there is uncertainities around
licensing and distribution of non-free packages.
Installation
============

593
docs/devenv.rst Normal file
View File

@ -0,0 +1,593 @@
.. _devenv:
***********************
Development environment
***********************
This page describes a common development setup for working with Mopidy and
Mopidy extensions. Of course, there may be other ways that work better for you
and the tools you use, but here's one recommended way to do it.
.. contents::
:local:
Initial setup
=============
The following steps help you get a good initial setup. They build on each other
to some degree, so if you're not very familiar with Python development it might
be wise to proceed in the order laid out here.
.. contents::
:local:
Install Mopidy the regular way
------------------------------
Install Mopidy the regular way. Mopidy has some non-Python dependencies which
may be tricky to install. Thus we recommend to always start with a full regular
Mopidy install, as described in :ref:`installation`. That is, if you're running
e.g. Debian, start with installing Mopidy from Debian packages.
Make a development workspace
----------------------------
Make a directory to be used as a workspace for all your Mopidy development::
mkdir ~/mopidy-dev
It will contain all the Git repositories you'll check out when working on
Mopidy and extensions.
Make a virtualenv
-----------------
Make a Python `virtualenv <https://virtualenv.pypa.io/>`_ for Mopidy
development. The virtualenv will wall off Mopidy and its dependencies from the
rest of your system. All development and installation of Python dependencies,
versions of Mopidy, and extensions are done inside the virtualenv. This way
your regular Mopidy install, which you set up in the first step, is unaffected
by your hacking and will always be working.
Most of us use the `virtualenvwrapper
<https://virtualenvwrapper.readthedocs.org/>`_ to ease working with
virtualenvs, so that's what we'll be using for the examples here. First,
install and setup virtualenvwrapper as described in their docs.
To create a virtualenv named ``mopidy`` which uses Python 2.7, allows access to
system-wide packages like GStreamer, and uses the Mopidy workspace directory as
the "project path", run::
mkvirtualenv -a ~/mopidy-dev --python `which python2.7` \
--system-site-packages mopidy
Now, each time you open a terminal and want to activate the ``mopidy``
virtualenv, run::
workon mopidy
This will both activate the ``mopidy`` virtualenv, and change the current
working directory to ``~/mopidy-dev``.
Clone the repo from GitHub
--------------------------
Once inside the virtualenv, it's time to clone the ``mopidy/mopidy`` Git repo
from GitHub::
git clone https://github.com/mopidy/mopidy.git
When you've cloned the ``mopidy`` Git repo, ``cd`` into it::
cd ~/mopidy-dev/mopidy/
With a fresh clone of the Git repo, you should start out on the ``develop``
branch. This is where all features for the next feature release land. To
confirm that you're on the right branch, run::
git branch
Install development tools
-------------------------
We use a number of Python development tools. The :file:`dev-requirements.txt`
file has comments describing what we use each dependency for, so we might just
as well include the file verbatim here:
.. literalinclude:: ../dev-requirements.txt
Install them all into the active virtualenv by running `pip
<https://pip.pypa.io/>`_::
pip install --upgrade -r dev-requirements.txt
To upgrade the tools in the future, just rerun the exact same command.
Install Mopidy from the Git repo
--------------------------------
Next up, we'll want to run Mopidy from the Git repo. There's two reasons for
this: first of all, it lets you easily change the source code, restart Mopidy,
and see the change take effect. Second, it's a convenient way to keep at the
bleeding edge, testing the latest developments in Mopidy itself or test some
extension against the latest Mopidy changes.
Assuming you're still inside the Git repo, use pip to install Mopidy from the
Git repo in an "editable" form::
pip install --editable .
This will not copy the source code into the virtualenv's ``site-packages``
directory, but instead create a link there pointing to the Git repo. Using
``cdsitepackages`` from virtualenvwrapper, we can quickly show that the
installed :file:`Mopidy.egg-link` file points back to the Git repo::
$ cdsitepackages
$ cat Mopidy.egg-link
/home/user/mopidy-dev/mopidy
.%
$
It will also create a ``mopidy`` executable inside the virtualenv that will
always run the latest code from the Git repo. Using another
virtualenvwrapper command, ``cdvirtualenv``, we can show that too::
$ cdvirtualenv
$ cat bin/mopidy
...
The executable should contain something like this, using :mod:`pkg_resources`
to look up Mopidy's "console script" entry point::
#!/home/user/virtualenvs/mopidy/bin/python2
# EASY-INSTALL-ENTRY-SCRIPT: 'Mopidy==0.19.5','console_scripts','mopidy'
__requires__ = 'Mopidy==0.19.5'
import sys
from pkg_resources import load_entry_point
if __name__ == '__main__':
sys.exit(
load_entry_point('Mopidy==0.19.5', 'console_scripts', 'mopidy')()
)
.. note::
It still works to run ``python mopidy`` directly on the
:file:`~/mopidy-dev/mopidy/mopidy/` Python package directory, but if
you don't run the ``pip install`` command above, the extensions bundled
with Mopidy will not be registered with :mod:`pkg_resources`, making Mopidy
quite useless.
Third, the ``pip install`` command will register the bundled Mopidy
extensions so that Mopidy may find them through :mod:`pkg_resources`. The
result of this can be seen in the Git repo, in a new directory called
:file:`Mopidy.egg-info`, which is ignored by Git. The
:file:`Mopidy.egg-info/entry_points.txt` file is of special interest as it
shows both how the above executable and the bundled extensions are connected to
the Mopidy source code:
.. code-block:: ini
[console_scripts]
mopidy = mopidy.__main__:main
[mopidy.ext]
http = mopidy.http:Extension
local = mopidy.local:Extension
mpd = mopidy.mpd:Extension
softwaremixer = mopidy.softwaremixer:Extension
stream = mopidy.stream:Extension
.. warning::
It's not uncommon to clean up in the Git repo now and then, e.g. by running
``git clean``.
If you do this, then the :file:`Mopidy.egg-info` directory will be removed,
and :mod:`pkg_resources` will no longer know how to locate the "console
script" entry point or the bundled Mopidy extensions.
The fix is simply to run the install command again::
pip install --editable .
Finally, we can go back to the workspace, again using a virtualenvwrapper
tool::
cdproject
.. _running-from-git:
Running Mopidy from Git
=======================
As long as the virtualenv is activated, you can start Mopidy from any
directory. Simply run::
mopidy
To stop it again, press :kbd:`Ctrl+C`.
Every time you change code in Mopidy or an extension and want to see it
live, you must restart Mopidy.
If you want to iterate quickly while developing, it may sound a bit tedious to
restart Mopidy for every minor change. Then it's useful to have tests to
exercise your code...
.. _running-tests:
Running tests
=============
Mopidy has quite good test coverage, and we would like all new code going into
Mopidy to come with tests.
.. contents::
:local:
Test it all
-----------
You need to know at least one command; the one that runs all the tests::
tox
This will run exactly the same tests as `Travis CI
<https://travis-ci.org/mopidy/mopidy>`_ runs for all our branches and pull
requests. If this command turns green, you can be quite confident that your
pull request will get the green flag from Travis as well, which is a
requirement for it to be merged.
As this is the ultimate test command, it's also the one taking the most time to
run; up to a minute, depending on your system. But, if you have patience, this
is all you need to know. Always run this command before pushing your changes to
GitHub.
If you take a look at the tox config file, :file:`tox.ini`, you'll see that tox
runs tests in multiple environments, including a ``flake8`` environment that
lints the source code for issues and a ``docs`` environment that tests that the
documentation can be built. You can also limit tox to just test specific
environments using the ``-e`` option, e.g. to run just unit tests::
tox -e py27
To learn more, see the `tox documentation <http://tox.readthedocs.org/>`_ .
Running unit tests
------------------
Under the hood, ``tox -e py27`` will use `pytest <http://pytest.org/>`_ as the
test runner. We can also use it directly to run all tests::
py.test
py.test has lots of possibilities, so you'll have to dive into their docs and
plugins to get full benefit from it. To get you interested, here are some
examples.
We can limit to just tests in a single directory to save time::
py.test tests/http/
With the help of the pytest-xdist plugin, we can run tests with four Python
processes in parallel, which usually cuts the test time in half or more::
py.test -n 4
Another useful feature from pytest-xdist, is the possiblity to stop on the
first test failure, watch the file system for changes, and then rerun the
tests. This makes for a very quick code-test cycle::
py.test -f # or --looponfail
With the help of the pytest-cov plugin, we can get a report on what parts of
the given module, ``mopidy`` in this example, are covered by the test suite::
py.test --cov=mopidy --cov-report=term-missing
.. note::
Up to date test coverage statistics can also be viewed online at
`coveralls.io <https://coveralls.io/r/mopidy/mopidy>`_.
If we want to speed up the test suite, we can even get a list of the ten
slowest tests::
py.test --durations=10
By now, you should be convinced that running py.test directly during
development can be very useful.
Continuous integration
----------------------
Mopidy uses the free service `Travis CI <https://travis-ci.org/mopidy/mopidy>`_
for automatically running the test suite when code is pushed to GitHub. This
works both for the main Mopidy repo, but also for any forks. This way, any
contributions to Mopidy through GitHub will automatically be tested by Travis
CI, and the build status will be visible in the GitHub pull request interface,
making it easier to evaluate the quality of pull requests.
For each successful build, Travis submits code coverage data to `coveralls.io
<https://coveralls.io/r/mopidy/mopidy>`_. If you're out of work, coveralls might
help you find areas in the code which could need better test coverage.
In addition, we run a Jenkins CI server at https://ci.mopidy.com/ that runs all
tests on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push
to the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code
isn't tested by Jenkins before it is merged into the ``develop`` branch, which
is a bit late, but good enough to get broad testing before new code is
released.
.. _code-linting:
Style checking and linting
--------------------------
We're quite pedantic about :ref:`codestyle` and try hard to keep the Mopidy
code base a very clean and nice place to work in.
Luckily, you can get very far by using the `flake8
<http://flake8.readthedocs.org/>`_ linter to check your code for issues before
submitting a pull request. Mopidy passes all of flake8's checks, with only a
very few exceptions configured in :file:`setup.cfg`. You can either run the
``flake8`` tox environment, like Travis CI will do on your pull request::
tox -e flake8
Or you can run flake8 directly::
flake8
If successful, the command will not print anything at all.
.. note::
In some rare cases it doesn't make sense to listen to flake8's warnings. In
those cases, ignore the check by appending ``# noqa: <warning code>`` to
the source line that triggers the warning. The ``# noqa`` part will make
flake8 skip all checks on the line, while the warning code will help other
developers lookup what you are ignoring.
.. _writing-docs:
Writing documentation
=====================
To write documentation, we use `Sphinx <http://sphinx-doc.org/>`_. See their
site for lots of documentation on how to use Sphinx.
.. note::
To generate a few graphs which are part of the documentation, you need some
additional dependencies. You can install them from APT with::
sudo apt-get install python-pygraphviz graphviz
To build the documentation, go into the :file:`docs/` directory::
cd ~/mopidy-dev/mopidy/docs/
Then, to see all available build targets, run::
make
To generate an HTML version of the documentation, run::
make html
The generated HTML will be available at :file:`_build/html/index.html`. To open
it in a browser you can run either of the following commands, depending on your
OS::
xdg-open _build/html/index.html # Linux
open _build/html/index.html # OS X
The documentation at https://docs.mopidy.com/ is hosted by `Read the Docs
<https://www.readhtedocs.org/>`_, which automatically updates the documentation
when a change is pushed to the ``mopidy/mopidy`` repo at GitHub.
Working on extensions
=====================
Much of the above also applies to Mopidy extensions, though they're often a bit
simpler. They don't have documentation sites and their test suites are either
small and fast, or sadly missing entirely. Most of them use tox and flake8, and
py.test can be used to run their test suites.
.. contents::
:local:
Installing extensions
---------------------
As always, the ``mopidy`` virtualenv should be active when working on
extensions::
workon mopidy
Just like with non-development Mopidy installations, you can install extensions
using pip::
pip install Mopidy-Scrobbler
Installing an extension from its Git repo works the same way as with Mopidy
itself. First, go to the Mopidy workspace::
cdproject # or cd ~/mopidy-dev/
Clone the desired Mopidy extension::
git clone https://github.com/mopidy/mopidy-spotify.git
Change to the newly created extension directory::
cd mopidy-spotify/
Then, install the extension in "editable" mode, so that it can be imported from
anywhere inside the virtualenv and the extension is registered and discoverable
through :mod:`pkg_resources`::
pip install --editable .
Every extension will have a ``README.rst`` file. It may contain information
about extra dependencies required, development process, etc. Extensions usually
have a changelog in the readme file.
Upgrading extensions
--------------------
Extensions often have a much quicker life cycle than Mopidy itself, often with
daily releases in periods of active development. To find outdated extensions in
your virtualenv, you can run::
pip search mopidy
This will list all available Mopidy extensions and compare the installed
versions with the latest available ones.
To upgrade an extension installed with pip, simply use pip::
pip install --upgrade Mopidy-Scrobbler
To upgrade an extension installed from a Git repo, it's usually enough to pull
the new changes in::
cd ~/mopidy-dev/mopidy-spotify/
git pull
Of course, if you have local modifications, you'll need to stash these away on
a branch or similar first.
Depending on the changes to the extension, it may be necessary to update the
metadata about the extension package by installing it in "editable" mode
again::
pip install --editable .
Contribution workflow
=====================
Before you being, make sure you've read the :ref:`contributing` page and the
guidelines there. This section will focus more on the practical workflow.
For the examples, we're making a change to Mopidy. Approximately the same
workflow should work for most Mopidy extensions too.
.. contents::
:local:
Setting up Git remotes
----------------------
Assuming we already have a local Git clone of the upstream Git repo in
:file:`~/mopidy-dev/mopidy/`, we can run ``git remote -v`` to list the
configured remotes of the repo::
$ git remote -v
origin https://github.com/mopidy/mopidy.git (fetch)
origin https://github.com/mopidy/mopidy.git (push)
For clarity, we can rename the ``origin`` remote to ``upstream``::
$ git remote rename origin upstream
$ git remote -v
upstream https://github.com/mopidy/mopidy.git (fetch)
upstream https://github.com/mopidy/mopidy.git (push)
If you haven't already, `fork the repository
<https://help.github.com/articles/fork-a-repo/>`_ to your own GitHub account.
Then, add the new fork as a remote to your local clone::
git remote add myuser git@github.com:myuser/mopidy.git
The end result is that you have both the upstream repo and your own fork as
remotes::
$ git remote -v
myuser git@github.com:myuser/mopidy.git (fetch)
myuser git@github.com:myuser/mopidy.git (push)
upstream https://github.com/mopidy/mopidy.git (fetch)
upstream https://github.com/mopidy/mopidy.git (push)
Creating a branch
-----------------
Fetch the latest data from all remotes without affecting your working
directory::
git remote update
Now, we are ready to create and checkout a new branch off of the upstream
``develop`` branch for our work::
git checkout -b fix/666-crash-on-foo upstream/develop
Do the work, while remembering to adhere to code style, test the changes, make
necessary updates to the documentation, and making small commits with good
commit messages. All as described in :ref:`contributing` and elsewhere in
the :ref:`devenv` guide.
Creating a pull request
-----------------------
When everything is done and committed, push the branch to your fork on GitHub::
git push myuser fix/666-crash-on-foo
Go to the repository on GitHub where you want the change merged, in this case
https://github.com/mopidy/mopidy, and `create a pull request
<https://help.github.com/articles/creating-a-pull-request/>`_.
Updating a pull request
-----------------------
When the pull request is created, `Travis CI
<https://travis-ci.org/mopidy/mopidy>`__ will run all tests on it. If something
fails, you'll get notified by email. You might as well just fix the issues
right away, as we won't merge a pull request without a green Travis build. See
:ref:`running-tests` on how to run the same tests locally as Travis CI runs on
your pull request.
When you've fixed the issues, you can update the pull request simply by pushing
more commits to the same branch in your fork::
git push myuser fix/666-crash-on-foo
Likewise, when you get review comments from other developers on your pull
request, you're expected to create additional commits which addresses the
comments. Push them to your branch so that the pull request is updated.
.. note::
Setup the remote as the default push target for your branch::
git branch --set-upstream-to myuser/fix/666-crash-on-foo
Then you can push more commits without specifying the remote::
git push

View File

@ -90,6 +90,17 @@ Mopidy-Local
Bundled with Mopidy. See :ref:`ext-local`.
Mopidy-Local-Images
===================
https://github.com/tkem/mopidy-local-images
Extension which plugs into Mopidy-Local to allow Web clients access to
album art embedded in local media files. Not to be used on its own,
but acting as a proxy between ``mopidy local scan`` and the actual
local library provider being used.
Mopidy-Local-SQLite
===================

View File

@ -73,17 +73,21 @@ See :ref:`config` for general help on configuring Mopidy.
.. confval:: http/static_dir
**Deprecated:** This config is deprecated and will be removed in a future
version of Mopidy.
Which directory the HTTP server should serve at "/"
Change this to have Mopidy serve e.g. files for your JavaScript client.
"/mopidy" will continue to work as usual even if you change this setting.
"/mopidy" will continue to work as usual even if you change this setting,
but any other Mopidy webclient installed with pip to be served at
"/ext_name" will stop working if you set this config.
This config value isn't deprecated yet, but you're strongly encouraged to
make Mopidy extensions which use the the :ref:`http-server-api` to host
static files on Mopidy's web server instead of using
:confval:`http/static_dir`. That way, installation of your web client will
be a lot easier for your end users, and multiple web clients can easily
share the same web server.
You're strongly encouraged to make Mopidy extensions which use the the
:ref:`http-server-api` to host static files on Mopidy's web server instead
of using :confval:`http/static_dir`. That way, installation of your web
client will be a lot easier for your end users, and multiple web clients
can easily share the same web server.
.. confval:: http/zeroconf

BIN
docs/ext/local_images.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

55
docs/ext/m3u.rst Normal file
View File

@ -0,0 +1,55 @@
.. _ext-m3u:
**********
Mopidy-M3U
**********
Mopidy-M3U is an extension for reading and writing M3U playlists stored
on disk. It is bundled with Mopidy and enabled by default.
This backend handles URIs starting with ``m3u:``.
.. _m3u-migration:
Migrating from Mopidy-Local playlists
=====================================
Mopidy-M3U was split out of the Mopidy-Local extension in Mopidy 1.0. To
migrate your playlists from Mopidy-Local, simply move them from the
:confval:`local/playlists_dir` directory to the :confval:`m3u/playlists_dir`
directory. Assuming you have not changed the default config, run the following
commands to migrate::
mkdir -p ~/.local/share/mopidy/m3u/
mv ~/.local/share/mopidy/local/playlists/* ~/.local/share/mopidy/m3u/
Editing playlists
=================
There is a core playlist API in place for editing playlists. This is supported
by a few Mopidy clients, but not through Mopidy's MPD server yet.
It is possible to edit playlists by editing the M3U files located in the
:confval:`m3u/playlists_dir` directory, usually
:file:`~/.local/share/mopidy/m3u/`, by hand with a text editor. See `Wikipedia
<https://en.wikipedia.org/wiki/M3U>`__ for a short description of the quite
simple M3U playlist format.
Configuration
=============
See :ref:`config` for general help on configuring Mopidy.
.. literalinclude:: ../../mopidy/m3u/ext.conf
:language: ini
.. confval:: m3u/enabled
If the M3U extension should be enabled or not.
.. confval:: m3u/playlists_dir
Path to directory with M3U files.

BIN
docs/ext/mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
docs/ext/mopify.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

View File

@ -99,3 +99,10 @@ See :ref:`config` for general help on configuring Mopidy.
``$hostname`` and ``$port`` can be used in the name.
Set to an empty string to disable Zeroconf for MPD.
.. confval:: mpd/command_blacklist
List of MPD commands which are disabled by the server. By default this
setting blacklists ``listall`` and ``listallinfo``. These commands don't
fit well with many of Mopidy's backends and are better left disabled unless
you know what you are doing.

View File

@ -30,17 +30,39 @@ To install, run::
pip install Mopidy-API-Explorer
Mopidy-HTTP-Kuechenradio
=========================
Mopidy-Local-Images
===================
https://github.com/tkem/mopidy-http-kuechenradio
https://github.com/tkem/mopidy-local-images
A deliberately simple Mopidy Web client for mobile devices. Made with jQuery
Mobile by Thomas Kemmer.
Not a full-featured Web client, but rather a local library and Web
extension which allows other Web clients access to album art embedded
in local media files.
.. image:: /ext/local_images.jpg
:width: 640
:height: 480
To install, run::
pip install Mopidy-HTTP-Kuechenradio
pip install Mopidy-Local-Images
Mopidy-Mobile
=============
https://github.com/tkem/mopidy-mobile
A Mopidy Web client extension and hybrid mobile app, made with Ionic,
AngularJS and Apache Cordova by Thomas Kemmer.
.. image:: /ext/mobile.png
:width: 1024
:height: 606
To install, run::
pip install Mopidy-Mobile
Mopidy-Moped
@ -64,12 +86,13 @@ Mopidy-Mopify
https://github.com/dirkgroenen/mopidy-mopify
An web client that mainly targets using Spotify through Mopidy. Made by Dirk
Groenen.
A web client that uses external web services to provide additional features and
a more "complete" Spotify music experience. It's currently targeted at people
using Spotify through Mopidy. Made by Dirk Groenen.
.. image:: /ext/mopify.png
:width: 720
:height: 424
.. image:: /ext/mopify.jpg
:width: 800
:height: 416
To install, run::

View File

@ -189,11 +189,6 @@ class that will connect the rest of the dots.
'Pykka >= 1.1',
'pysoundspot',
],
test_suite='nose.collector',
tests_require=[
'nose',
'mock >= 1.0',
],
entry_points={
'mopidy.ext': [
'soundspot = mopidy_soundspot:Extension',
@ -312,12 +307,6 @@ This is ``mopidy_soundspot/__init__.py``::
from .backend import SoundspotBackend
registry.add('backend', SoundspotBackend)
# Register a custom GStreamer element
from .mixer import SoundspotMixer
gobject.type_register(SoundspotMixer)
gst.element_register(
SoundspotMixer, 'soundspotmixer', gst.RANK_MARGINAL)
# Or nothing to register e.g. command extension
pass
@ -421,17 +410,6 @@ examples, see the :ref:`http-server-api` docs or explore with
:ref:`http-explore-extension` extension.
Example GStreamer element
=========================
If you want to extend Mopidy's GStreamer pipeline with new custom GStreamer
elements, you'll need to register them in GStreamer before they can be used.
Basically, you just implement your GStreamer element in Python and then make
your :meth:`~mopidy.ext.Extension.setup` method register all your custom
GStreamer elements.
Running an extension
====================

View File

@ -94,6 +94,7 @@ Extensions
:maxdepth: 2
ext/local
ext/m3u
ext/stream
ext/http
ext/mpd
@ -132,10 +133,11 @@ Development
===========
.. toctree::
:maxdepth: 1
:maxdepth: 2
contributing
devtools
devenv
releasing
codestyle
extensiondev

View File

@ -14,14 +14,27 @@ If you are running Arch Linux, you can install Mopidy using the
To upgrade Mopidy to future releases, just upgrade your system using::
yaourt -Syu
#. Optional: If you want to use any Mopidy extensions, like Spotify support or
Last.fm scrobbling, AUR also has `packages for several Mopidy extensions
<https://aur.archlinux.org/packages/?K=mopidy>`_.
For a full list of available Mopidy extensions, including those not
installable from AUR, see :ref:`ext`.
yaourt -Syua
#. Finally, you need to set a couple of :doc:`config values </config>`, and
then you're ready to :doc:`run Mopidy </running>`.
Installing extensions
=====================
If you want to use any Mopidy extensions, like Spotify support or Last.fm
scrobbling, AUR also has `packages for lots of Mopidy extensions
<https://aur.archlinux.org/packages/?K=mopidy>`_.
You can also install any Mopidy extension directly from PyPI with ``pip``. To
list all the extensions available from PyPI, run::
pip search mopidy
Note that extensions installed from PyPI will only automatically install Python
dependencies. Please refer to the extension's documentation for information
about any other requirements needed for the extension to work properly.
For a full list of available Mopidy extensions, including those not installable
from AUR, see :ref:`ext`.

View File

@ -52,20 +52,6 @@ from scratch, we have a guide for installing Debian/Raspbian and Mopidy. See
sudo apt-get update
sudo apt-get install mopidy
#. Optional: If you want to use any Mopidy extensions, like Spotify support or
Last.fm scrobbling, you need to install additional packages.
To list all the extensions available from apt.mopidy.com, you can run::
apt-cache search mopidy
To install one of the listed packages, e.g. ``mopidy-spotify``, simply run::
sudo apt-get install mopidy-spotify
For a full list of available Mopidy extensions, including those not
installable from apt.mopidy.com, see :ref:`ext`.
#. Before continuing, make sure you've read the :ref:`debian` section to learn
about the differences between running Mopidy as a system service and
manually as your own system user.
@ -78,3 +64,71 @@ figure it out for itself, run the following to upgrade right away::
sudo apt-get update
sudo apt-get dist-upgrade
Installing extensions
=====================
If you want to use any Mopidy extensions, like Spotify support or Last.fm
scrobbling, you need to install additional packages.
To list all the extensions available from apt.mopidy.com, you can run::
apt-cache search mopidy
To install one of the listed packages, e.g. ``mopidy-spotify``, simply run::
sudo apt-get install mopidy-spotify
You can also install any Mopidy extension directly from PyPI with ``pip``. To
list all the extensions available from PyPI, run::
pip search mopidy
Note that extensions installed from PyPI will only automatically install Python
dependencies. Please refer to the extension's documentation for information
about any other requirements needed for the extension to work properly.
For a full list of available Mopidy extensions, including those not
installable from apt.mopidy.com, see :ref:`ext`.
Missing extensions
==================
If you've installed a Mopidy extension with pip, restarted Mopidy, and Mopidy
doesn't find the extension, there's probably a simple explanation and solution.
Mopidy installed with APT can detect and use Mopidy extensions installed with
both APT and pip. APT installs Mopidy as :file:`/usr/bin/mopidy`.
Mopidy installed with pip can only detect Mopidy extensions installed with pip.
pip usually installs Mopidy as :file:`/usr/local/bin/mopidy`.
If you have Mopidy installed from both APT and pip, then the pip-installed
Mopidy will probably shadow the APT-installed Mopidy because
:file:`/usr/local/bin` usually has precedence over :file:`/usr/bin` in the
``PATH`` environment variable. To check if this is the case on your system, you
can use ``which`` to see what installation of Mopidy you use when you run
``mopidy`` in your shell::
$ which mopidy
/usr/local/bin/mopidy
If this is the case on your system, the recommended solution is to check that
you have Mopidy installed from APT too::
$ /usr/bin/mopidy --version
Mopidy 0.19.5
And then uninstall the pip-installed Mopidy::
sudo pip uninstall mopidy
Depending on what shell you use, the shell may still try to use
:file:`/usr/local/bin/mopidy` even if it no longer exists. Check again with
``which mopidy`` what your shell believes is the right ``mopidy`` executable to
run. If the shell is still confused, you may need to restart it, or in the case
of zsh, run ``rehash`` to update the shell.
For more details on why this works this way, see :ref:`debian`.

View File

@ -7,8 +7,9 @@ Installation
There are several ways to install Mopidy. What way is best depends upon your OS
and/or distribution.
If you want to contribute to the development of Mopidy, you should first read
the general installation instructions, then have a look at :ref:`run-from-git`.
If you want to contribute to the development of Mopidy, you should first follow
the instructions here to install a regular install of Mopidy, then continue
with reading :ref:`contributing` and :ref:`devenv`.
.. toctree::

View File

@ -57,16 +57,77 @@ If you are running OS X, you can install everything needed with Homebrew.
brew install mopidy
#. Optional: If you want to use any Mopidy extensions, like Spotify support or
Last.fm scrobbling, the Homebrew tap has formulas for several Mopidy
extensions as well.
#. Finally, you need to set a couple of :doc:`config values </config>`, and
then you're ready to :doc:`run Mopidy </running>`.
Installing extensions
=====================
If you want to use any Mopidy extensions, like Spotify support or Last.fm
scrobbling, the Homebrew tap has formulas for several Mopidy extensions as
well. Extensions installed from Homebrew will come complete with all
dependencies, both Python and non-Python ones.
To list all the extensions available from our tap, you can run::
brew search mopidy
For a full list of available Mopidy extensions, including those not
installable from Homebrew, see :ref:`ext`.
You can also install any Mopidy extension directly from PyPI with ``pip``, just
like on Linux. To list all the extensions available from PyPI, run::
#. Finally, you need to set a couple of :doc:`config values </config>`, and
then you're ready to :doc:`run Mopidy </running>`.
pip search mopidy
Note that extensions installed from PyPI will only automatically install Python
dependencies. Please refer to the extension's documentation for information
about any other requirements needed for the extension to work properly.
For a full list of available Mopidy extensions, including those not installable
from Homebrew, see :ref:`ext`.
Running Mopidy automatically on login
=====================================
On OS X, you can use launchd to start Mopidy automatically at login.
If you installed Mopidy from Homebrew, simply run ``brew info mopidy`` and
follow the instructions in the "Caveats" section::
$ brew info mopidy
...
==> Caveats
To have launchd start mopidy at login:
ln -sfv /usr/local/opt/mopidy/*.plist ~/Library/LaunchAgents
Then to load mopidy now:
launchctl load ~/Library/LaunchAgents/homebrew.mopidy.mopidy.plist
Or, if you don't want/need launchctl, you can just run:
mopidy
If you happen to be on OS X, but didn't install Mopidy with Homebrew, you can
get the same effect by adding the file
:file:`~/Library/LaunchAgents/mopidy.plist` with the following contents::
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>mopidy</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/mopidy</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
You might need to adjust the path to the ``mopidy`` executable,
``/usr/local/bin/mopidy``, to match your system.
Then, to start Mopidy with launchd right away::
launchctl load ~/Library/LaunchAgents/mopidy.plist

View File

@ -6,7 +6,10 @@ Install from source
If you are on Linux, but can't install :ref:`from the APT archive
<debian-install>` or :ref:`from AUR <arch-install>`, you can install Mopidy
from source by hand.
from PyPI using the ``pip`` installer.
If you are looking to contribute or wish to install from source using ``git``
please follow the directions :ref:`here <contributing>`.
#. First of all, you need Python 2.7. Check if you have Python and what
version by running::
@ -69,46 +72,32 @@ from source by hand.
sudo pip install -U mopidy
To upgrade Mopidy to future releases, just rerun this command.
This will use ``pip`` to install the latest release of `Mopidy from PyPI
<https://pypi.python.org/pypi/Mopidy>`_. To upgrade Mopidy to future
releases, just rerun this command.
Alternatively, if you want to track Mopidy development closer, you may
install a snapshot of Mopidy's ``develop`` Git branch using pip::
sudo pip install --allow-unverified=mopidy mopidy==dev
#. Optional: If you want Spotify support in Mopidy, you'll need to install
libspotify and the Mopidy-Spotify extension.
#. Download and install the latest version of libspotify for your OS and CPU
architecture from `Spotify
<https://developer.spotify.com/technologies/libspotify/>`_.
For libspotify 12.1.51 for 64-bit Linux the process is as follows::
wget https://developer.spotify.com/download/libspotify/libspotify-12.1.51-Linux-x86_64-release.tar.gz
tar zxfv libspotify-12.1.51-Linux-x86_64-release.tar.gz
cd libspotify-12.1.51-Linux-x86_64-release/
sudo make install prefix=/usr/local
Remember to adjust the above example for the latest libspotify version
supported by pyspotify, your OS, and your CPU architecture.
#. If you're on Fedora, you must add a configuration file so libspotify.so
can be found::
echo /usr/local/lib | sudo tee /etc/ld.so.conf.d/libspotify.conf
sudo ldconfig
#. Then install the latest release of Mopidy-Spotify using pip::
sudo pip install -U mopidy-spotify
#. Optional: If you want to scrobble your played tracks to Last.fm, you need
to install Mopidy-Scrobbler::
sudo pip install -U mopidy-scrobbler
#. For a full list of available Mopidy extensions, see :ref:`ext`.
#. Finally, you need to set a couple of :doc:`config values </config>`, and
then you're ready to :doc:`run Mopidy </running>`.
Installing extensions
=====================
If you want to use any Mopidy extensions, like Spotify support or Last.fm
scrobbling, you need to install additional Mopidy extensions.
You can install any Mopidy extension directly from PyPI with ``pip``. To list
all the extensions available from PyPI, run::
pip search mopidy
Note that extensions installed from PyPI will only automatically install Python
dependencies. Please refer to the extension's documentation for information
about any other requirements needed for the extension to work properly.
For a full list of available Mopidy extensions see :ref:`ext`.

View File

@ -1,51 +1,10 @@
*****************
Development tools
*****************
******************
Release procedures
******************
Here you'll find description of the development tools we use.
Continuous integration
======================
Mopidy uses the free service `Travis CI <https://travis-ci.org/mopidy/mopidy>`_
for automatically running the test suite when code is pushed to GitHub. This
works both for the main Mopidy repo, but also for any forks. This way, any
contributions to Mopidy through GitHub will automatically be tested by Travis
CI, and the build status will be visible in the GitHub pull request interface,
making it easier to evaluate the quality of pull requests.
In addition, we run a Jenkins CI server at http://ci.mopidy.com/ that runs all
test on multiple platforms (Ubuntu, OS X, x86, arm) for every commit we push to
the ``develop`` branch in the main Mopidy repo on GitHub. Thus, new code isn't
tested by Jenkins before it is merged into the ``develop`` branch, which is a
bit late, but good enough to get broad testing before new code is released.
In addition to running tests, the Jenkins CI server also gathers coverage
statistics and uses flake8 to check for errors and possible improvements in our
code. So, if you're out of work, the code coverage and flake8 data at the CI
server should give you a place to start.
Documentation writing
=====================
To write documentation, we use `Sphinx <http://sphinx-doc.org/>`_. See their
site for lots of documentation on how to use Sphinx. To generate HTML from the
documentation files, you need some additional dependencies.
You can install them through Debian/Ubuntu package management::
sudo apt-get install python-sphinx python-pygraphviz graphviz
Then, to generate docs::
cd docs/
make # For help on available targets
make html # To generate HTML docs
The documentation at http://docs.mopidy.com/ is automatically updated when a
documentation update is pushed to ``mopidy/mopidy`` at GitHub.
Here we try to keep an up to date record of how Mopidy releases are made. This
documentation serves both as a checklist, to reduce the project's dependency on
key individuals, and as a stepping stone to more automation.
Creating releases

View File

@ -4,9 +4,9 @@
Troubleshooting
***************
If you run into problems with Mopidy, we usually hang around at ``#mopidy`` at
`irc.freenode.net <http://freenode.net/>`_ and also have a `mailing list at
Google Groups <https://groups.google.com/forum/?fromgroups=#!forum/mopidy>`_.
If you run into problems with Mopidy, we usually hang around at ``#mopidy`` on
`irc.freenode.net <http://freenode.net/>`_ and also have a `discussion forum
<https://discuss.mopidy.com/c/mopidy>`_.
If you stumble into a bug or have a feature request, please create an issue in
the `issue tracker <https://github.com/mopidy/mopidy/issues>`_.

View File

@ -1,23 +1,39 @@
.. _versioning:
**********
Versioning
**********
Mopidy uses `Semantic Versioning <http://semver.org/>`_, but since we're still
pre-1.0 that doesn't mean much yet.
Mopidy follows `Semantic Versioning <http://semver.org/>`_. In summary this
means that our version numbers have three parts, MAJOR.MINOR.PATCH, which
change according to the following rules:
- When we *make incompatible API changes*, we increase the MAJOR number.
- When we *add features* in a backwards-compatible manner, we increase the
MINOR number.
- When we *fix bugs* in a backwards-compatible manner, we increase the PATCH
number.
The promise is that if you make a Mopidy extension for Mopidy 1.0, it should
work unchanged with any Mopidy 1.x release, but probably not with 2.0. When a
new major version is released, you must review the incompatible changes and
update your extension accordingly.
Release schedule
================
We intend to have about one feature release every month in periods of active
development. The feature releases are numbered 0.x.0. The features added is a
mix of what we feel is most important/requested of the missing features, and
features we develop just because we find them fun to make, even though they may
be useful for very few users or for a limited use case.
development. The features added is a mix of what we feel is most
important/requested of the missing features, and features we develop just
because we find them fun to make, even though they may be useful for very few
users or for a limited use case.
Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs
that are too serious to wait for the next feature release. We will only release
bugfix releases for the last feature release. E.g. when 0.14.0 is released, we
will no longer provide bugfix releases for the 0.13 series. In other words,
there will be just a single supported release at any point in time. This is to
not spread our limited resources too thin.
Bugfix releases will be released whenever we discover bugs that are too serious
to wait for the next feature release. We will only release bugfix releases for
the last feature release. E.g. when 1.2.0 is released, we will no longer
provide bugfix releases for the 1.1.x series. In other words, there will be just
a single supported release at any point in time. This is to not spread our
limited resources too thin.

View File

@ -30,4 +30,4 @@ except ImportError:
warnings.filterwarnings('ignore', 'could not open display')
__version__ = '0.19.5'
__version__ = '1.0.0'

View File

@ -2,7 +2,6 @@ from __future__ import absolute_import, unicode_literals
# flake8: noqa
from .actor import Audio
from .dummy import DummyAudio
from .listener import AudioListener
from .constants import PlaybackState
from .utils import (

View File

@ -16,7 +16,7 @@ from mopidy import exceptions
from mopidy.audio import playlists, utils
from mopidy.audio.constants import PlaybackState
from mopidy.audio.listener import AudioListener
from mopidy.utils import process
from mopidy.utils import deprecation, process
logger = logging.getLogger(__name__)
@ -34,26 +34,11 @@ _GST_STATE_MAPPING = {
gst.STATE_PAUSED: PlaybackState.PAUSED,
gst.STATE_NULL: PlaybackState.STOPPED}
MB = 1 << 20
# GST_PLAY_FLAG_VIDEO (1<<0)
# GST_PLAY_FLAG_AUDIO (1<<1)
# GST_PLAY_FLAG_TEXT (1<<2)
# GST_PLAY_FLAG_VIS (1<<3)
# GST_PLAY_FLAG_SOFT_VOLUME (1<<4)
# GST_PLAY_FLAG_NATIVE_AUDIO (1<<5)
# GST_PLAY_FLAG_NATIVE_VIDEO (1<<6)
# GST_PLAY_FLAG_DOWNLOAD (1<<7)
# GST_PLAY_FLAG_BUFFERING (1<<8)
# GST_PLAY_FLAG_DEINTERLACE (1<<9)
# GST_PLAY_FLAG_SOFT_COLORBALANCE (1<<10)
# Default flags to use for playbin: AUDIO, SOFT_VOLUME, DOWNLOAD
PLAYBIN_FLAGS = (1 << 1) | (1 << 4) | (1 << 7)
class _Signals(object):
"""Helper for tracking gobject signal registrations"""
def __init__(self):
self._ids = {}
@ -82,7 +67,9 @@ class _Signals(object):
# TODO: expose this as a property on audio?
class _Appsrc(object):
"""Helper class for dealing with appsrc based playback."""
def __init__(self):
self._signals = _Signals()
self.reset()
@ -112,7 +99,7 @@ class _Appsrc(object):
source.set_property('caps', self._caps)
source.set_property('format', b'time')
source.set_property('stream-type', b'seekable')
source.set_property('max-bytes', 1 * MB)
source.set_property('max-bytes', 1 << 20) # 1MB
source.set_property('min-percent', 50)
if self._need_data_callback:
@ -128,6 +115,9 @@ class _Appsrc(object):
self._source = source
def push(self, buffer_):
if self._source is None:
return False
if buffer_ is None:
gst_logger.debug('Sending appsrc end-of-stream event.')
return self._source.emit('end-of-stream') == gst.FLOW_OK
@ -146,27 +136,14 @@ class _Appsrc(object):
# TODO: expose this as a property on audio when #790 gets further along.
class _Outputs(gst.Bin):
def __init__(self):
gst.Bin.__init__(self)
gst.Bin.__init__(self, 'outputs')
self._tee = gst.element_factory_make('tee')
self.add(self._tee)
# Queue element to buy us time between the about to finish event and
# the actual switch, i.e. about to switch can block for longer thanks
# to this queue.
# TODO: make the min-max values a setting?
# TODO: this does not belong in this class.
queue = gst.element_factory_make('queue')
queue.set_property('max-size-buffers', 0)
queue.set_property('max-size-bytes', 0)
queue.set_property('max-size-time', 5 * gst.SECOND)
queue.set_property('min-threshold-time', 3 * gst.SECOND)
self.add(queue)
queue.link(self._tee)
ghost_pad = gst.GhostPad('sink', queue.get_pad('sink'))
ghost_pad = gst.GhostPad('sink', self._tee.get_pad('sink'))
self.add_pad(ghost_pad)
# Add an always connected fakesink which respects the clock so the tee
@ -190,7 +167,9 @@ class _Outputs(gst.Bin):
def _add(self, element):
# All tee branches need a queue in front of them.
# But keep the queue short so the volume change isn't to slow:
queue = gst.element_factory_make('queue')
queue.set_property('max-size-buffers', 5)
self.add(element)
self.add(queue)
queue.link(element)
@ -209,10 +188,6 @@ class SoftwareMixer(object):
def setup(self, element, mixer_ref):
self._element = element
self._signals.connect(element, 'notify::volume', self._volume_changed)
self._signals.connect(element, 'notify::mute', self._mute_changed)
self._mixer.setup(mixer_ref)
def teardown(self):
@ -224,41 +199,20 @@ class SoftwareMixer(object):
def set_volume(self, volume):
self._element.set_property('volume', volume / 100.0)
self._mixer.trigger_volume_changed(volume)
def get_mute(self):
return self._element.get_property('mute')
def set_mute(self, mute):
return self._element.set_property('mute', bool(mute))
def _volume_changed(self, element, property_):
old_volume, self._last_volume = self._last_volume, self.get_volume()
if old_volume != self._last_volume:
gst_logger.debug('Notify volume: %s', self._last_volume / 100.0)
self._mixer.trigger_volume_changed(self._last_volume)
def _mute_changed(self, element, property_):
old_mute, self._last_mute = self._last_mute, self.get_mute()
if old_mute != self._last_mute:
gst_logger.debug('Notify mute: %s', self._last_mute)
self._mixer.trigger_mute_changed(self._last_mute)
def setup_proxy(element, config):
# TODO: reuse in scanner code
if not config.get('hostname'):
return
proxy = "%s://%s:%d" % (config.get('scheme', 'http'),
config.get('hostname'),
config.get('port', 80))
element.set_property('proxy', proxy)
element.set_property('proxy-id', config.get('username'))
element.set_property('proxy-pw', config.get('password'))
result = self._element.set_property('mute', bool(mute))
if result:
self._mixer.trigger_mute_changed(bool(mute))
return result
class _Handler(object):
def __init__(self, audio):
self._audio = audio
self._element = None
@ -290,7 +244,7 @@ class _Handler(object):
if msg.type == gst.MESSAGE_STATE_CHANGED and msg.src == self._element:
self.on_playbin_state_changed(*msg.parse_state_changed())
elif msg.type == gst.MESSAGE_BUFFERING:
self.on_buffering(msg.parse_buffering())
self.on_buffering(msg.parse_buffering(), msg.structure)
elif msg.type == gst.MESSAGE_EOS:
self.on_end_of_stream()
elif msg.type == gst.MESSAGE_ERROR:
@ -353,16 +307,23 @@ class _Handler(object):
gst.DEBUG_BIN_TO_DOT_FILE(
self._audio._playbin, gst.DEBUG_GRAPH_SHOW_ALL, 'mopidy')
def on_buffering(self, percent):
gst_logger.debug('Got buffering message: percent=%d%%', percent)
def on_buffering(self, percent, structure=None):
if structure and structure.has_field('buffering-mode'):
if structure['buffering-mode'] == gst.BUFFERING_LIVE:
return # Live sources stall in paused.
level = logging.getLevelName('TRACE')
if percent < 10 and not self._audio._buffering:
self._audio._playbin.set_state(gst.STATE_PAUSED)
self._audio._buffering = True
level = logging.DEBUG
if percent == 100:
self._audio._buffering = False
if self._audio._target_state == gst.STATE_PLAYING:
self._audio._playbin.set_state(gst.STATE_PLAYING)
level = logging.DEBUG
gst_logger.log(level, 'Got buffering message: percent=%d%%', percent)
def on_end_of_stream(self):
gst_logger.debug('Got end-of-stream message.')
@ -420,6 +381,7 @@ class _Handler(object):
# TODO: create a player class which replaces the actors internals
class Audio(pykka.ThreadingActor):
"""
Audio output through `GStreamer <http://gstreamer.freedesktop.org/>`_.
"""
@ -453,8 +415,8 @@ class Audio(pykka.ThreadingActor):
try:
self._setup_preferences()
self._setup_playbin()
self._setup_output()
self._setup_mixer()
self._setup_outputs()
self._setup_audio_sink()
except gobject.GError as ex:
logger.exception(ex)
process.exit_process()
@ -474,11 +436,11 @@ class Audio(pykka.ThreadingActor):
def _setup_playbin(self):
playbin = gst.element_factory_make('playbin2')
playbin.set_property('flags', PLAYBIN_FLAGS)
playbin.set_property('flags', 2) # GST_PLAY_FLAG_AUDIO
# TODO: turn into config values...
playbin.set_property('buffer-size', 2*1024*1024)
playbin.set_property('buffer-duration', 2*gst.SECOND)
playbin.set_property('buffer-size', 5 << 20) # 5MB
playbin.set_property('buffer-duration', 5 * gst.SECOND)
self._signals.connect(playbin, 'source-setup', self._on_source_setup)
self._signals.connect(playbin, 'about-to-finish',
@ -494,7 +456,7 @@ class Audio(pykka.ThreadingActor):
self._signals.disconnect(self._playbin, 'source-setup')
self._playbin.set_state(gst.STATE_NULL)
def _setup_output(self):
def _setup_outputs(self):
# We don't want to use outputs for regular testing, so just install
# an unsynced fakesink when someone asks for a 'testoutput'.
if self._config['audio']['output'] == 'testoutput':
@ -507,11 +469,36 @@ class Audio(pykka.ThreadingActor):
process.exit_process() # TODO: move this up the chain
self._handler.setup_event_handling(self._outputs.get_pad('sink'))
self._playbin.set_property('audio-sink', self._outputs)
def _setup_mixer(self):
def _setup_audio_sink(self):
audio_sink = gst.Bin('audio-sink')
# Queue element to buy us time between the about to finish event and
# the actual switch, i.e. about to switch can block for longer thanks
# to this queue.
# TODO: make the min-max values a setting?
queue = gst.element_factory_make('queue')
queue.set_property('max-size-buffers', 0)
queue.set_property('max-size-bytes', 0)
queue.set_property('max-size-time', 3 * gst.SECOND)
queue.set_property('min-threshold-time', 1 * gst.SECOND)
audio_sink.add(queue)
audio_sink.add(self._outputs)
if self.mixer:
self.mixer.setup(self._playbin, self.actor_ref.proxy().mixer)
volume = gst.element_factory_make('volume')
audio_sink.add(volume)
queue.link(volume)
volume.link(self._outputs)
self.mixer.setup(volume, self.actor_ref.proxy().mixer)
else:
queue.link(self._outputs)
ghost_pad = gst.GhostPad('sink', queue.get_pad('sink'))
audio_sink.add_pad(ghost_pad)
self._playbin.set_property('audio-sink', audio_sink)
def _teardown_mixer(self):
if self.mixer:
@ -531,8 +518,7 @@ class Audio(pykka.ThreadingActor):
else:
self._appsrc.reset()
if hasattr(source.props, 'proxy'):
setup_proxy(source, self._config['proxy'])
utils.setup_proxy(source, self._config['proxy'])
def set_uri(self, uri):
"""
@ -543,9 +529,20 @@ class Audio(pykka.ThreadingActor):
:param uri: the URI to play
:type uri: string
"""
# XXX: Hack to workaround issue on Mac OS X where volume level
# does not persist between track changes. mopidy/mopidy#886
if self.mixer is not None:
current_volume = self.mixer.get_volume()
else:
current_volume = None
self._tags = {} # TODO: add test for this somehow
self._playbin.set_property('uri', uri)
if self.mixer is not None and current_volume is not None:
self.mixer.set_volume(current_volume)
def set_appsrc(
self, caps, need_data=None, enough_data=None, seek_data=None):
"""
@ -594,9 +591,10 @@ class Audio(pykka.ThreadingActor):
We will get a GStreamer message when the stream playback reaches the
token, and can then do any end-of-stream related tasks.
.. deprecated:: 0.20
.. deprecated:: 1.0
Use :meth:`emit_data` with a :class:`None` buffer instead.
"""
deprecation.warn('audio.emit_end_of_stream')
self._appsrc.push(None)
def set_about_to_finish_callback(self, callback):

View File

@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals
class PlaybackState(object):
"""
Enum of playback states.
"""

View File

@ -4,6 +4,7 @@ from mopidy import listener
class AudioListener(listener.Listener):
"""
Marker interface for recipients of events sent by the audio actor.

View File

@ -136,6 +136,7 @@ def register_typefinders():
class BasePlaylistElement(gst.Bin):
"""Base class for creating GStreamer elements for playlist support.
This element performs the following steps:

View File

@ -1,42 +1,38 @@
from __future__ import absolute_import, division, unicode_literals
import time
import collections
import pygst
pygst.require('0.10')
import gst # noqa
import gst.pbutils
from mopidy import exceptions
from mopidy.audio import utils
from mopidy.utils import encoding
_missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description
_Result = collections.namedtuple(
'Result', ('uri', 'tags', 'duration', 'seekable', 'mime'))
_RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float')
# TODO: replace with a scan(uri, timeout=1000, proxy_config=None)?
class Scanner(object):
"""
Helper to get tags and other relevant info from URIs.
:param timeout: timeout for scanning a URI in ms
:param proxy_config: dictionary containing proxy config strings.
:type event: int
"""
def __init__(self, timeout=1000):
self._timeout_ms = timeout
sink = gst.element_factory_make('fakesink')
audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float')
pad_added = lambda src, pad: pad.link(sink.get_pad('sink'))
self._uribin = gst.element_factory_make('uridecodebin')
self._uribin.set_property('caps', audio_caps)
self._uribin.connect('pad-added', pad_added)
self._pipe = gst.element_factory_make('pipeline')
self._pipe.add(self._uribin)
self._pipe.add(sink)
self._bus = self._pipe.get_bus()
self._bus.set_flushing(True)
def __init__(self, timeout=1000, proxy_config=None):
self._timeout_ms = int(timeout)
self._proxy_config = proxy_config or {}
def scan(self, uri):
"""
@ -44,64 +40,72 @@ class Scanner(object):
:param uri: URI of the resource to scan.
:type event: string
:return: (tags, duration) pair. tags is a dictionary of lists for all
the tags we found and duration is the length of the URI in
milliseconds, or :class:`None` if the URI has no duration.
:return: A named tuple containing
``(uri, tags, duration, seekable, mime)``.
``tags`` is a dictionary of lists for all the tags we found.
``duration`` is the length of the URI in milliseconds, or
:class:`None` if the URI has no duration. ``seekable`` is boolean.
indicating if a seek would succeed.
"""
tags, duration = None, None
tags, duration, seekable, mime = None, None, None, None
pipeline = _setup_pipeline(uri, self._proxy_config)
try:
self._setup(uri)
tags = self._collect()
duration = self._query_duration()
_start_pipeline(pipeline)
tags, mime = _process(pipeline, self._timeout_ms)
duration = _query_duration(pipeline)
seekable = _query_seekable(pipeline)
finally:
self._reset()
pipeline.set_state(gst.STATE_NULL)
del pipeline
return tags, duration
return _Result(uri, tags, duration, seekable, mime)
def _setup(self, uri):
"""Primes the pipeline for collection."""
self._pipe.set_state(gst.STATE_READY)
self._uribin.set_property(b'uri', uri)
self._bus.set_flushing(False)
result = self._pipe.set_state(gst.STATE_PAUSED)
if result == gst.STATE_CHANGE_NO_PREROLL:
# Live sources don't pre-roll, so set to playing to get data.
self._pipe.set_state(gst.STATE_PLAYING)
def _collect(self):
"""Polls for messages to collect data."""
start = time.time()
timeout_s = self._timeout_ms / 1000.0
tags = {}
# Turns out it's _much_ faster to just create a new pipeline for every as
# decodebins and other elements don't seem to take well to being reused.
def _setup_pipeline(uri, proxy_config=None):
src = gst.element_make_from_uri(gst.URI_SRC, uri)
if not src:
raise exceptions.ScannerError('GStreamer can not open: %s' % uri)
while time.time() - start < timeout_s:
if not self._bus.have_pending():
continue
message = self._bus.pop()
typefind = gst.element_factory_make('typefind')
decodebin = gst.element_factory_make('decodebin2')
sink = gst.element_factory_make('fakesink')
if message.type == gst.MESSAGE_ERROR:
raise exceptions.ScannerError(
encoding.locale_decode(message.parse_error()[0]))
elif message.type == gst.MESSAGE_EOS:
return tags
elif message.type == gst.MESSAGE_ASYNC_DONE:
if message.src == self._pipe:
return tags
elif message.type == gst.MESSAGE_TAG:
taglist = message.parse_tag()
# Note that this will only keep the last tag.
tags.update(utils.convert_taglist(taglist))
pipeline = gst.element_factory_make('pipeline')
for e in (src, typefind, decodebin, sink):
pipeline.add(e)
gst.element_link_many(src, typefind, decodebin)
raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms)
if proxy_config:
utils.setup_proxy(src, proxy_config)
def _reset(self):
"""Ensures we cleanup child elements and flush the bus."""
self._bus.set_flushing(True)
self._pipe.set_state(gst.STATE_NULL)
decodebin.set_property('caps', _RAW_AUDIO)
decodebin.connect('pad-added', _pad_added, sink)
typefind.connect('have-type', _have_type, decodebin)
def _query_duration(self):
return pipeline
def _have_type(element, probability, caps, decodebin):
decodebin.set_property('sink-caps', caps)
msg = gst.message_new_application(element, caps.get_structure(0))
element.get_bus().post(msg)
def _pad_added(element, pad, sink):
return pad.link(sink.get_pad('sink'))
def _start_pipeline(pipeline):
if pipeline.set_state(gst.STATE_PAUSED) == gst.STATE_CHANGE_NO_PREROLL:
pipeline.set_state(gst.STATE_PLAYING)
def _query_duration(pipeline):
try:
duration = self._pipe.query_duration(gst.FORMAT_TIME, None)[0]
duration = pipeline.query_duration(gst.FORMAT_TIME, None)[0]
except gst.QueryError:
return None
@ -109,3 +113,52 @@ class Scanner(object):
return None
else:
return duration // gst.MSECOND
def _query_seekable(pipeline):
query = gst.query_new_seeking(gst.FORMAT_TIME)
pipeline.query(query)
return query.parse_seeking()[1]
def _process(pipeline, timeout_ms):
clock = pipeline.get_clock()
bus = pipeline.get_bus()
timeout = timeout_ms * gst.MSECOND
tags, mime, missing_description = {}, None, None
types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR
| gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG)
start = clock.get_time()
while timeout > 0:
message = bus.timed_pop_filtered(timeout, types)
if message is None:
break
elif message.type == gst.MESSAGE_ELEMENT:
if gst.pbutils.is_missing_plugin_message(message):
missing_description = encoding.locale_decode(
_missing_plugin_desc(message))
elif message.type == gst.MESSAGE_APPLICATION:
mime = message.structure.get_name()
if mime.startswith('text/') or mime == 'application/xml':
return tags, mime
elif message.type == gst.MESSAGE_ERROR:
error = encoding.locale_decode(message.parse_error()[0])
if missing_description:
error = '%s (%s)' % (missing_description, error)
raise exceptions.ScannerError(error)
elif message.type == gst.MESSAGE_EOS:
return tags, mime
elif message.type == gst.MESSAGE_ASYNC_DONE:
if message.src == pipeline:
return tags, mime
elif message.type == gst.MESSAGE_TAG:
taglist = message.parse_tag()
# Note that this will only keep the last tag.
tags.update(utils.convert_taglist(taglist))
timeout -= clock.get_time() - start
raise exceptions.ScannerError('Timeout after %dms' % timeout_ms)

View File

@ -82,7 +82,8 @@ def _artists(tags, artist_name, artist_id=None):
def convert_tags_to_track(tags):
"""Convert our normalized tags to a track.
:param :class:`dict` tags: dictionary of tag keys with a list of values
:param tags: dictionary of tag keys with a list of values
:type tags: :class:`dict`
:rtype: :class:`mopidy.models.Track`
"""
album_kwargs = {}
@ -130,6 +131,26 @@ def convert_tags_to_track(tags):
return Track(**track_kwargs)
def setup_proxy(element, config):
"""Configure a GStreamer element with proxy settings.
:param element: element to setup proxy in.
:type element: :class:`gst.GstElement`
:param config: proxy settings to use.
:type config: :class:`dict`
"""
if not hasattr(element.props, 'proxy') or not config.get('hostname'):
return
proxy = "%s://%s:%d" % (config.get('scheme', 'http'),
config.get('hostname'),
config.get('port', 80))
element.set_property('proxy', proxy)
element.set_property('proxy-id', config.get('username'))
element.set_property('proxy-pw', config.get('password'))
def convert_taglist(taglist):
"""Convert a :class:`gst.Taglist` to plain Python types.
@ -147,7 +168,8 @@ def convert_taglist(taglist):
.. _GstTagList: http://gstreamer.freedesktop.org/data/doc/gstreamer/\
0.10.36/gstreamer/html/gstreamer-GstTagList.html
:param gst.Taglist taglist: A GStreamer taglist to be converted.
:param taglist: A GStreamer taglist to be converted.
:type taglist: :class:`gst.Taglist`
:rtype: dictionary of tag keys with a list of values.
"""
result = {}

View File

@ -1,11 +1,10 @@
from __future__ import absolute_import, unicode_literals
import copy
from mopidy import listener
from mopidy import listener, models
class Backend(object):
"""Backend API
If the backend has problems during initialization it should raise
@ -61,6 +60,7 @@ class Backend(object):
class LibraryProvider(object):
"""
:param backend: backend the controller is a part of
:type backend: :class:`mopidy.backend.Backend`
@ -92,14 +92,34 @@ class LibraryProvider(object):
"""
return []
# TODO: replace with search(query, exact=True, ...)
def find_exact(self, query=None, uris=None):
def get_distinct(self, field, query=None):
"""
See :meth:`mopidy.core.LibraryController.find_exact`.
See :meth:`mopidy.core.LibraryController.get_distinct`.
*MAY be implemented by subclass.*
Default implementation will simply return an empty set.
"""
pass
return set()
def get_images(self, uris):
"""
See :meth:`mopidy.core.LibraryController.get_images`.
*MAY be implemented by subclass.*
Default implementation will simply call lookup and try and use the
album art for any tracks returned. Most extensions should replace this
with something smarter or simply return an empty dictionary.
"""
result = {}
for uri in uris:
image_uris = set()
for track in self.lookup(uri):
if track.album and track.album.images:
image_uris.update(track.album.images)
result[uri] = [models.Image(uri=u) for u in image_uris]
return result
def lookup(self, uri):
"""
@ -117,16 +137,20 @@ class LibraryProvider(object):
"""
pass
def search(self, query=None, uris=None):
def search(self, query=None, uris=None, exact=False):
"""
See :meth:`mopidy.core.LibraryController.search`.
*MAY be implemented by subclass.*
.. versionadded:: 1.0
The ``exact`` param which replaces the old ``find_exact``.
"""
pass
class PlaybackProvider(object):
"""
:param audio: the audio actor
:type audio: actor proxy to an instance of :class:`mopidy.audio.Audio`
@ -172,23 +196,44 @@ class PlaybackProvider(object):
"""
self.audio.prepare_change().get()
def translate_uri(self, uri):
"""
Convert custom URI scheme to real playable URI.
*MAY be reimplemented by subclass.*
This is very likely the *only* thing you need to override as a backend
author. Typically this is where you convert any Mopidy specific URI
to a real URI and then return it. If you can't convert the URI just
return :class:`None`.
:param uri: the URI to translate
:type uri: string
:rtype: string or :class:`None` if the URI could not be translated
"""
return uri
def change_track(self, track):
"""
Swith to provided track.
*MAY be reimplemented by subclass.*
This is very likely the *only* thing you need to override as a backend
author. Typically this is where you convert any mopidy specific URIs
to real URIs and then return::
It is unlikely it makes sense for any backends to override
this. For most practical purposes it should be considered an internal
call between backends and core that backend authors should not touch.
return super(MyBackend, self).change_track(track.copy(uri=new_uri))
The default implementation will call :meth:`translate_uri` which
is what you want to implement.
:param track: the track to play
:type track: :class:`mopidy.models.Track`
:rtype: :class:`True` if successful, else :class:`False`
"""
self.audio.set_uri(track.uri).get()
uri = self.translate_uri(track.uri)
if not uri:
return False
self.audio.set_uri(uri).get()
return True
def resume(self):
@ -219,7 +264,7 @@ class PlaybackProvider(object):
*MAY be reimplemented by subclass.*
Should not be used for tracking if tracks have been played / when we
Should not be used for tracking if tracks have been played or when we
are done playing them.
:rtype: :class:`True` if successful, else :class:`False`
@ -238,6 +283,7 @@ class PlaybackProvider(object):
class PlaylistsProvider(object):
"""
A playlist provider exposes a collection of playlists, methods to
create/change/delete playlists in this collection, and lookup of any
@ -251,25 +297,36 @@ class PlaylistsProvider(object):
def __init__(self, backend):
self.backend = backend
self._playlists = []
# TODO Replace playlists property with a get_playlists() method which
# returns playlist Ref's instead of the gigantic data structures we
# currently make available. lookup() should be used for getting full
# playlists with all details.
@property
def playlists(self):
def as_list(self):
"""
Currently available playlists.
Get a list of the currently available playlists.
Read/write. List of :class:`mopidy.models.Playlist`.
Returns a list of :class:`~mopidy.models.Ref` objects referring to the
playlists. In other words, no information about the playlists' content
is given.
:rtype: list of :class:`mopidy.models.Ref`
.. versionadded:: 1.0
"""
return copy.copy(self._playlists)
raise NotImplementedError
@playlists.setter # noqa
def playlists(self, playlists):
self._playlists = playlists
def get_items(self, uri):
"""
Get the items in a playlist specified by ``uri``.
Returns a list of :class:`~mopidy.models.Ref` objects referring to the
playlist's items.
If a playlist with the given ``uri`` doesn't exist, it returns
:class:`None`.
:rtype: list of :class:`mopidy.models.Ref`, or :class:`None`
.. versionadded:: 1.0
"""
raise NotImplementedError
def create(self, name):
"""
@ -338,6 +395,7 @@ class PlaylistsProvider(object):
class BackendListener(listener.Listener):
"""
Marker interface for recipients of events sent by the backend actors.

View File

@ -2,11 +2,9 @@ from __future__ import absolute_import, print_function, unicode_literals
import argparse
import collections
import contextlib
import logging
import os
import sys
import time
import glib
@ -15,7 +13,7 @@ import gobject
from mopidy import config as config_lib, exceptions
from mopidy.audio import Audio
from mopidy.core import Core
from mopidy.utils import deps, process, versioning
from mopidy.utils import deps, process, timer, versioning
logger = logging.getLogger(__name__)
@ -40,7 +38,9 @@ def config_override_type(value):
class _ParserError(Exception):
pass
def __init__(self, message):
self.message = message
class _HelpError(Exception):
@ -48,11 +48,13 @@ class _HelpError(Exception):
class _ArgumentParser(argparse.ArgumentParser):
def error(self, message):
raise _ParserError(message)
class _HelpAction(argparse.Action):
def __init__(self, option_strings, dest=None, help=None):
super(_HelpAction, self).__init__(
option_strings=option_strings,
@ -65,14 +67,8 @@ class _HelpAction(argparse.Action):
raise _HelpError()
@contextlib.contextmanager
def _startup_timer(name):
start = time.time()
yield
logger.debug('%s startup took %dms', name, (time.time() - start) * 1000)
class Command(object):
"""Command parser and runner for building trees of commands.
This class provides a wraper around :class:`argparse.ArgumentParser`
@ -236,6 +232,7 @@ class Command(object):
# TODO: move out of this file
class RootCommand(Command):
def __init__(self):
super(RootCommand, self).__init__()
self.set(base_verbosity_level=0)
@ -277,10 +274,12 @@ class RootCommand(Command):
exit_status_code = 0
try:
mixer = None
if mixer_class is not None:
mixer = self.start_mixer(config, mixer_class)
audio = self.start_audio(config, mixer)
backends = self.start_backends(config, backend_classes, audio)
core = self.start_core(audio, mixer, backends)
core = self.start_core(mixer, backends, audio)
self.start_frontends(config, frontend_classes, core)
loop.run()
except (exceptions.BackendError,
@ -298,6 +297,7 @@ class RootCommand(Command):
self.stop_core()
self.stop_backends(backend_classes)
self.stop_audio()
if mixer_class is not None:
self.stop_mixer(mixer_class)
process.stop_remaining_actors()
return exit_status_code
@ -307,13 +307,18 @@ class RootCommand(Command):
'Available Mopidy mixers: %s',
', '.join(m.__name__ for m in mixer_classes) or 'none')
if config['audio']['mixer'] == 'none':
logger.debug('Mixer disabled')
return None
selected_mixers = [
m for m in mixer_classes if m.name == config['audio']['mixer']]
if len(selected_mixers) != 1:
logger.error(
'Did not find unique mixer "%s". Alternatives are: %s',
config['audio']['mixer'],
', '.join([m.name for m in mixer_classes]))
', '.join([m.name for m in mixer_classes]) + ', none' or
'none')
process.exit_process()
return selected_mixers[0]
@ -349,7 +354,7 @@ class RootCommand(Command):
backends = []
for backend_class in backend_classes:
try:
with _startup_timer(backend_class.__name__):
with timer.time_logger(backend_class.__name__):
backend = backend_class.start(
config=config, audio=audio).proxy()
backends.append(backend)
@ -361,9 +366,9 @@ class RootCommand(Command):
return backends
def start_core(self, audio, mixer, backends):
def start_core(self, mixer, backends, audio):
logger.info('Starting Mopidy core')
return Core.start(audio=audio, mixer=mixer, backends=backends).proxy()
return Core.start(mixer=mixer, backends=backends, audio=audio).proxy()
def start_frontends(self, config, frontend_classes, core):
logger.info(
@ -372,7 +377,7 @@ class RootCommand(Command):
for frontend_class in frontend_classes:
try:
with _startup_timer(frontend_class.__name__):
with timer.time_logger(frontend_class.__name__):
frontend_class.start(config=config, core=core)
except exceptions.FrontendError as exc:
logger.error(

View File

@ -22,7 +22,8 @@ _logging_schema['debug_format'] = String()
_logging_schema['debug_file'] = Path()
_logging_schema['config_file'] = Path(optional=True)
_loglevels_schema = LogLevelConfigSchema('loglevels')
_loglevels_schema = MapConfigSchema('loglevels', LogLevel())
_logcolors_schema = MapConfigSchema('logcolors', LogColor())
_audio_schema = ConfigSchema('audio')
_audio_schema['mixer'] = String()
@ -42,7 +43,8 @@ _proxy_schema['password'] = Secret(optional=True)
# NOTE: if multiple outputs ever comes something like LogLevelConfigSchema
# _outputs_schema = config.AudioOutputConfigSchema()
_schemas = [_logging_schema, _loglevels_schema, _audio_schema, _proxy_schema]
_schemas = [_logging_schema, _loglevels_schema, _logcolors_schema,
_audio_schema, _proxy_schema]
_INITIAL_HELP = """
# For further information about options in this file see:
@ -148,6 +150,11 @@ def _load_file(parser, filename):
logger.debug(
'Loading config from %s failed; it does not exist', filename)
return
if not os.access(filename, os.R_OK):
logger.warning(
'Loading config from %s failed; read permission missing',
filename)
return
try:
logger.info('Loading config from %s', filename)
@ -170,13 +177,19 @@ def _validate(raw_config, schemas):
# Get validated config
config = {}
errors = {}
sections = set(raw_config)
for schema in schemas:
sections.discard(schema.name)
values = raw_config.get(schema.name, {})
result, error = schema.deserialize(values)
if error:
errors[schema.name] = error
if result:
config[schema.name] = result
for section in sections:
logger.debug('Ignoring unknown config section: %s', section)
return config, errors
@ -251,6 +264,7 @@ def _postprocess(config_string):
class Proxy(collections.Mapping):
def __init__(self, data):
self._data = data

View File

@ -38,6 +38,7 @@ def _levenshtein(a, b):
class ConfigSchema(collections.OrderedDict):
"""Logical group of config values that correspond to a config section.
Schemas are set up by assigning config keys with config values to
@ -47,6 +48,7 @@ class ConfigSchema(collections.OrderedDict):
:meth:`serialize` for converting the values to a form suitable for
persistence.
"""
def __init__(self, name):
super(ConfigSchema, self).__init__()
self.name = name
@ -94,17 +96,17 @@ class ConfigSchema(collections.OrderedDict):
return result
class LogLevelConfigSchema(object):
"""Special cased schema for handling a config section with loglevels.
class MapConfigSchema(object):
Expects the config keys to be logger names and the values to be log levels
as understood by the :class:`LogLevel` config value. Does not sub-class
:class:`ConfigSchema`, but implements the same serialize/deserialize
interface.
"""Schema for handling multiple unknown keys with the same type.
Does not sub-class :class:`ConfigSchema`, but implements the same
serialize/deserialize interface.
"""
def __init__(self, name):
def __init__(self, name, value_type):
self.name = name
self._config_value = types.LogLevel()
self._value_type = value_type
def deserialize(self, values):
errors = {}
@ -112,7 +114,7 @@ class LogLevelConfigSchema(object):
for key, value in values.items():
try:
result[key] = self._config_value.deserialize(value)
result[key] = self._value_type.deserialize(value)
except ValueError as e: # deserialization failed
result[key] = None
errors[key] = str(e)
@ -121,5 +123,5 @@ class LogLevelConfigSchema(object):
def serialize(self, values, display=False):
result = collections.OrderedDict()
for key in sorted(values.keys()):
result[key] = self._config_value.serialize(values[key], display)
result[key] = self._value_type.serialize(values[key], display)
return result

View File

@ -6,7 +6,7 @@ import socket
from mopidy import compat
from mopidy.config import validators
from mopidy.utils import path
from mopidy.utils import log, path
def decode(value):
@ -25,6 +25,7 @@ def encode(value):
class ExpandedPath(bytes):
def __new__(cls, original, expanded):
return super(ExpandedPath, cls).__new__(cls, expanded)
@ -37,6 +38,7 @@ class DeprecatedValue(object):
class ConfigValue(object):
"""Represents a config key's value and how to handle it.
Normally you will only be interacting with sub-classes for config values
@ -65,6 +67,7 @@ class ConfigValue(object):
class Deprecated(ConfigValue):
"""Deprecated value
Used for ignoring old config values that are no longer in use, but should
@ -79,10 +82,12 @@ class Deprecated(ConfigValue):
class String(ConfigValue):
"""String value.
Is decoded as utf-8 and \\n \\t escapes should work and be preserved.
"""
def __init__(self, optional=False, choices=None):
self._required = not optional
self._choices = choices
@ -102,6 +107,7 @@ class String(ConfigValue):
class Secret(String):
"""Secret string value.
Is decoded as utf-8 and \\n \\t escapes should work and be preserved.
@ -109,6 +115,7 @@ class Secret(String):
Should be used for passwords, auth tokens etc. Will mask value when being
displayed.
"""
def __init__(self, optional=False, choices=None):
self._required = not optional
self._choices = None # Choices doesn't make sense for secrets
@ -120,6 +127,7 @@ class Secret(String):
class Integer(ConfigValue):
"""Integer value."""
def __init__(
@ -141,6 +149,7 @@ class Integer(ConfigValue):
class Boolean(ConfigValue):
"""Boolean value.
Accepts ``1``, ``yes``, ``true``, and ``on`` with any casing as
@ -173,11 +182,13 @@ class Boolean(ConfigValue):
class List(ConfigValue):
"""List value.
Supports elements split by commas or newlines. Newlines take presedence and
empty list items will be filtered out.
"""
def __init__(self, optional=False):
self._required = not optional
@ -197,11 +208,24 @@ class List(ConfigValue):
return b'\n ' + b'\n '.join(encode(v) for v in value if v)
class LogColor(ConfigValue):
def deserialize(self, value):
validators.validate_choice(value.lower(), log.COLORS)
return value.lower()
def serialize(self, value, display=False):
if value.lower() in log.COLORS:
return value.lower()
return b''
class LogLevel(ConfigValue):
"""Log level value.
Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug``
with any casing.
Expects one of ``critical``, ``error``, ``warning``, ``info``, ``debug``,
or ``all``, with any casing.
"""
levels = {
b'critical': logging.CRITICAL,
@ -209,6 +233,7 @@ class LogLevel(ConfigValue):
b'warning': logging.WARNING,
b'info': logging.INFO,
b'debug': logging.DEBUG,
b'all': logging.NOTSET,
}
def deserialize(self, value):
@ -223,6 +248,7 @@ class LogLevel(ConfigValue):
class Hostname(ConfigValue):
"""Network hostname value."""
def __init__(self, optional=False):
@ -240,18 +266,21 @@ class Hostname(ConfigValue):
class Port(Integer):
"""Network port value.
Expects integer in the range 0-65535, zero tells the kernel to simply
allocate a port for us.
"""
# TODO: consider probing if port is free or not?
def __init__(self, choices=None, optional=False):
super(Port, self).__init__(
minimum=0, maximum=2 ** 16 - 1, choices=choices, optional=optional)
class Path(ConfigValue):
"""File system path
The following expansions of the path will be done:
@ -266,6 +295,7 @@ class Path(ConfigValue):
- ``$XDG_MUSIC_DIR`` according to the XDG spec
"""
def __init__(self, optional=False):
self._required = not optional

View File

@ -5,6 +5,7 @@ from .actor import Core
from .history import HistoryController
from .library import LibraryController
from .listener import CoreListener
from .mixer import MixerController
from .playback import PlaybackController, PlaybackState
from .playlists import PlaylistsController
from .tracklist import TracklistController

View File

@ -10,10 +10,12 @@ from mopidy.audio import PlaybackState
from mopidy.core.history import HistoryController
from mopidy.core.library import LibraryController
from mopidy.core.listener import CoreListener
from mopidy.core.mixer import MixerController
from mopidy.core.playback import PlaybackController
from mopidy.core.playlists import PlaylistsController
from mopidy.core.tracklist import TracklistController
from mopidy.utils import versioning
from mopidy.utils.deprecation import deprecated_property
class Core(
@ -28,6 +30,10 @@ class Core(
"""The playback history controller. An instance of
:class:`mopidy.core.HistoryController`."""
mixer = None
"""The mixer controller. An instance of
:class:`mopidy.core.MixerController`."""
playback = None
"""The playback controller. An instance of
:class:`mopidy.core.PlaybackController`."""
@ -40,43 +46,49 @@ class Core(
"""The tracklist controller. An instance of
:class:`mopidy.core.TracklistController`."""
def __init__(self, audio=None, mixer=None, backends=None):
def __init__(self, mixer=None, backends=None, audio=None):
super(Core, self).__init__()
self.backends = Backends(backends)
self.library = LibraryController(backends=self.backends, core=self)
self.history = HistoryController()
self.mixer = MixerController(mixer=mixer)
self.playback = PlaybackController(
audio=audio, mixer=mixer, backends=self.backends, core=self)
self.playlists = PlaylistsController(
backends=self.backends, core=self)
audio=audio, backends=self.backends, core=self)
self.playlists = PlaylistsController(backends=self.backends, core=self)
self.tracklist = TracklistController(core=self)
self.audio = audio
def get_uri_schemes(self):
"""Get list of URI schemes we can handle"""
futures = [b.uri_schemes for b in self.backends]
results = pykka.get_all(futures)
uri_schemes = itertools.chain(*results)
return sorted(uri_schemes)
uri_schemes = property(get_uri_schemes)
"""List of URI schemes we can handle"""
uri_schemes = deprecated_property(get_uri_schemes)
"""
.. deprecated:: 1.0
Use :meth:`get_uri_schemes` instead.
"""
def get_version(self):
"""Get version of the Mopidy core API"""
return versioning.get_version()
version = property(get_version)
"""Version of the Mopidy core API"""
version = deprecated_property(get_version)
"""
.. deprecated:: 1.0
Use :meth:`get_version` instead.
"""
def reached_end_of_stream(self):
self.playback.on_end_of_stream()
self.playback._on_end_of_stream()
def stream_changed(self, uri):
self.playback.on_stream_changed(uri)
self.playback._on_stream_changed(uri)
def state_changed(self, old_state, new_state, target_state):
# XXX: This is a temporary fix for issue #232 while we wait for a more
@ -88,8 +100,8 @@ class Core(
# We ignore cases when target state is set as this is buffering
# updates (at least for now) and we need to get #234 fixed...
if (new_state == PlaybackState.PAUSED and not target_state
and self.playback.state != PlaybackState.PAUSED):
if (new_state == PlaybackState.PAUSED and not target_state and
self.playback.state != PlaybackState.PAUSED):
self.playback.state = new_state
self.playback._trigger_track_playback_paused()
@ -105,8 +117,25 @@ class Core(
# Forward event from mixer to frontends
CoreListener.send('mute_changed', mute=mute)
def tags_changed(self, tags):
if not self.audio or 'title' not in tags:
return
tags = self.audio.get_current_tags().get()
if not tags:
return
# TODO: this limits us to only streams that set organization, this is
# a hack to make sure we don't emit stream title changes for plain
# tracks. We need a better way to decide if something is a stream.
if 'title' in tags and tags['title'] and 'organization' in tags:
title = tags['title'][0]
self.playback._stream_title = title
CoreListener.send('stream_title_changed', title=title)
class Backends(list):
def __init__(self, backends):
super(Backends, self).__init__(backends)
@ -116,7 +145,9 @@ class Backends(list):
self.with_playlists = collections.OrderedDict()
backends_by_scheme = {}
name = lambda b: b.actor_ref.actor_class.__name__
def name(b):
return b.actor_ref.actor_class.__name__
for b in backends:
has_library = b.has_library().get()

View File

@ -15,9 +15,11 @@ class HistoryController(object):
def __init__(self):
self._history = []
def add(self, track):
def _add_track(self, track):
"""Add track to the playback history.
Internal method for :class:`mopidy.core.PlaybackController`.
:param track: track to add
:type track: :class:`mopidy.models.Track`
"""

View File

@ -1,11 +1,17 @@
from __future__ import absolute_import, unicode_literals
import collections
import logging
import operator
import urlparse
import pykka
from mopidy.utils import deprecation
logger = logging.getLogger(__name__)
class LibraryController(object):
pykka_traversable = True
@ -60,6 +66,8 @@ class LibraryController(object):
:param string uri: URI to browse
:rtype: list of :class:`mopidy.models.Ref`
.. versionadded:: 0.18
"""
if uri is None:
backends = self.backends.with_library_browse.values()
@ -72,52 +80,65 @@ class LibraryController(object):
return []
return backend.library.browse(uri).get()
def find_exact(self, query=None, uris=None, **kwargs):
def get_distinct(self, field, query=None):
"""
Search the library for tracks where ``field`` is ``values``.
List distinct values for a given field from the library.
If the query is empty, and the backend can support it, all available
tracks are returned.
This has mainly been added to support the list commands the MPD
protocol supports in a more sane fashion. Other frontends are not
recommended to use this method.
If ``uris`` is given, the search is limited to results from within the
URI roots. For example passing ``uris=['file:']`` will limit the search
to the local backend.
:param string field: One of ``artist``, ``albumartist``, ``album``,
``composer``, ``performer``, ``date``or ``genre``.
:param dict query: Query to use for limiting results, see
:meth:`search` for details about the query format.
:rtype: set of values corresponding to the requested field type.
Examples::
# Returns results matching 'a' from any backend
find_exact({'any': ['a']})
find_exact(any=['a'])
# Returns results matching artist 'xyz' from any backend
find_exact({'artist': ['xyz']})
find_exact(artist=['xyz'])
# Returns results matching 'a' and 'b' and artist 'xyz' from any
# backend
find_exact({'any': ['a', 'b'], 'artist': ['xyz']})
find_exact(any=['a', 'b'], artist=['xyz'])
# Returns results matching 'a' if within the given URI roots
# "file:///media/music" and "spotify:"
find_exact(
{'any': ['a']}, uris=['file:///media/music', 'spotify:'])
find_exact(any=['a'], uris=['file:///media/music', 'spotify:'])
:param query: one or more queries to search for
:type query: dict
:param uris: zero or more URI roots to limit the search to
:type uris: list of strings or :class:`None`
:rtype: list of :class:`mopidy.models.SearchResult`
.. versionadded:: 1.0
"""
futures = [b.library.get_distinct(field, query)
for b in self.backends.with_library.values()]
result = set()
for r in pykka.get_all(futures):
result.update(r)
return result
def get_images(self, uris):
"""Lookup the images for the given URIs
Backends can use this to return image URIs for any URI they know about
be it tracks, albums, playlists... The lookup result is a dictionary
mapping the provided URIs to lists of images.
Unknown URIs or URIs the corresponding backend couldn't find anything
for will simply return an empty list for that URI.
:param list uris: list of URIs to find images for
:rtype: {uri: tuple of :class:`mopidy.models.Image`}
.. versionadded:: 1.0
"""
query = query or kwargs
futures = [
backend.library.find_exact(query=query, uris=backend_uris)
backend.library.get_images(backend_uris)
for (backend, backend_uris)
in self._get_backends_to_uris(uris).items()]
return [result for result in pykka.get_all(futures) if result]
in self._get_backends_to_uris(uris).items() if backend_uris]
def lookup(self, uri):
results = {uri: tuple() for uri in uris}
for r in pykka.get_all(futures):
for uri, images in r.items():
results[uri] += tuple(images)
return results
def find_exact(self, query=None, uris=None, **kwargs):
"""Search the library for tracks where ``field`` is ``values``.
.. deprecated:: 1.0
Use :meth:`search` with ``exact`` set.
"""
deprecation.warn('core.library.find_exact')
return self.search(query=query, uris=uris, exact=True, **kwargs)
def lookup(self, uri=None, uris=None):
"""
Lookup the given URI.
@ -125,14 +146,48 @@ class LibraryController(object):
them all.
:param uri: track URI
:type uri: string
:rtype: list of :class:`mopidy.models.Track`
:type uri: string or :class:`None`
:param uris: track URIs
:type uris: list of string or :class:`None`
:rtype: list of :class:`mopidy.models.Track` if uri was set or
a {uri: list of :class:`mopidy.models.Track`} if uris was set.
.. versionadded:: 1.0
The ``uris`` argument.
.. deprecated:: 1.0
The ``uri`` argument. Use ``uris`` instead.
"""
backend = self._get_backend(uri)
if backend:
return backend.library.lookup(uri).get()
none_set = uri is None and uris is None
both_set = uri is not None and uris is not None
if none_set or both_set:
raise ValueError("One of 'uri' or 'uris' must be set")
if uri:
deprecation.warn('core.library.lookup:uri_arg')
if uri is not None:
uris = [uri]
futures = {}
result = {}
backends = self._get_backends_to_uris(uris)
# TODO: lookup(uris) to backend APIs
for backend, backend_uris in backends.items():
for u in backend_uris or []:
futures[u] = backend.library.lookup(u)
for u in uris:
if u in futures:
result[u] = futures[u].get()
else:
return []
result[u] = []
if uri:
return result[uri]
return result
def refresh(self, uri=None):
"""
@ -150,13 +205,10 @@ class LibraryController(object):
for b in self.backends.with_library.values()]
pykka.get_all(futures)
def search(self, query=None, uris=None, **kwargs):
def search(self, query=None, uris=None, exact=False, **kwargs):
"""
Search the library for tracks where ``field`` contains ``values``.
If the query is empty, and the backend can support it, all available
tracks are returned.
If ``uris`` is given, the search is limited to results from within the
URI roots. For example passing ``uris=['file:']`` will limit the search
to the local backend.
@ -186,10 +238,58 @@ class LibraryController(object):
:param uris: zero or more URI roots to limit the search to
:type uris: list of strings or :class:`None`
:rtype: list of :class:`mopidy.models.SearchResult`
.. versionadded:: 1.0
The ``exact`` keyword argument, which replaces :meth:`find_exact`.
.. deprecated:: 1.0
Previously, if the query was empty, and the backend could support
it, all available tracks were returned. This has not changed, but
it is strongly discouraged. No new code should rely on this
behavior.
.. deprecated:: 1.1
Providing the search query via ``kwargs`` is no longer supported.
"""
query = query or kwargs
futures = [
backend.library.search(query=query, uris=backend_uris)
for (backend, backend_uris)
in self._get_backends_to_uris(uris).items()]
return [result for result in pykka.get_all(futures) if result]
query = _normalize_query(query or kwargs)
if kwargs:
deprecation.warn('core.library.search:kwargs_query')
if not query:
deprecation.warn('core.library.search:empty_query')
futures = {}
for backend, backend_uris in self._get_backends_to_uris(uris).items():
futures[backend] = backend.library.search(
query=query, uris=backend_uris, exact=exact)
results = []
for backend, future in futures.items():
try:
results.append(future.get())
except TypeError:
backend_name = backend.actor_ref.actor_class.__name__
logger.warning(
'%s does not implement library.search() with "exact" '
'support. Please upgrade it.', backend_name)
return [r for r in results if r]
def _normalize_query(query):
broken_client = False
for (field, values) in query.items():
if isinstance(values, basestring):
broken_client = True
query[field] = [values]
if broken_client:
logger.warning(
'A client or frontend made a broken library search. Values in '
'queries must be lists of strings, not a string. Please check what'
' sent this query and file a bug. Query: %s', query)
if not query:
logger.warning(
'A client or frontend made a library search with an empty query. '
'This is strongly discouraged. Please check what sent this query '
'and file a bug.')
return query

View File

@ -4,6 +4,7 @@ from mopidy import listener
class CoreListener(listener.Listener):
"""
Marker interface for recipients of events sent by the core actor.
@ -163,3 +164,11 @@ class CoreListener(listener.Listener):
:type time_position: int
"""
pass
def stream_title_changed(self, title):
"""
Called whenever the currently playing stream title changes.
*MAY* be implemented by actor.
"""
pass

58
mopidy/core/mixer.py Normal file
View File

@ -0,0 +1,58 @@
from __future__ import absolute_import, unicode_literals
import logging
logger = logging.getLogger(__name__)
class MixerController(object):
pykka_traversable = True
def __init__(self, mixer):
self._mixer = mixer
def get_volume(self):
"""Get the volume.
Integer in range [0..100] or :class:`None` if unknown.
The volume scale is linear.
"""
if self._mixer is not None:
return self._mixer.get_volume().get()
def set_volume(self, volume):
"""Set the volume.
The volume is defined as an integer in range [0..100].
The volume scale is linear.
Returns :class:`True` if call is successful, otherwise :class:`False`.
"""
if self._mixer is None:
return False
else:
return self._mixer.set_volume(volume).get()
def get_mute(self):
"""Get mute state.
:class:`True` if muted, :class:`False` unmuted, :class:`None` if
unknown.
"""
if self._mixer is not None:
return self._mixer.get_mute().get()
def set_mute(self, mute):
"""Set mute state.
:class:`True` to mute, :class:`False` to unmute.
Returns :class:`True` if call is successful, otherwise :class:`False`.
"""
if self._mixer is None:
return False
else:
return self._mixer.set_mute(bool(mute)).get()

View File

@ -5,29 +5,28 @@ import urlparse
from mopidy.audio import PlaybackState
from mopidy.core import listener
from mopidy.utils import deprecation
logger = logging.getLogger(__name__)
# TODO: split mixing out from playback?
class PlaybackController(object):
pykka_traversable = True
def __init__(self, audio, mixer, backends, core):
def __init__(self, audio, backends, core):
# TODO: these should be internal
self.mixer = mixer
self.backends = backends
self.core = core
self._audio = audio
self._stream_title = None
self._state = PlaybackState.STOPPED
self._volume = None
self._mute = False
self._current_tl_track = None
self._pending_tl_track = None
if self._audio:
self._audio.set_about_to_finish_callback(self.on_about_to_finish)
self._audio.set_about_to_finish_callback(self._on_about_to_finish)
def _get_backend(self, tl_track):
if tl_track is None:
@ -38,37 +37,56 @@ class PlaybackController(object):
# Properties
def get_current_tl_track(self):
return self.current_tl_track
"""Get the currently playing or selected track.
current_tl_track = None
Returns a :class:`mopidy.models.TlTrack` or :class:`None`.
"""
The currently playing or selected :class:`mopidy.models.TlTrack`, or
:class:`None`.
return self._current_tl_track
def _set_current_tl_track(self, value):
"""Set the currently playing or selected track.
*Internal:* This is only for use by Mopidy's test suite.
"""
self._current_tl_track = value
current_tl_track = deprecation.deprecated_property(get_current_tl_track)
"""
.. deprecated:: 1.0
Use :meth:`get_current_tl_track` instead.
"""
def get_current_track(self):
return self.current_tl_track and self.current_tl_track.track
current_track = property(get_current_track)
"""
The currently playing or selected :class:`mopidy.models.Track`.
Get the currently playing or selected track.
Read-only. Extracted from :attr:`current_tl_track` for convenience.
Extracted from :meth:`get_current_tl_track` for convenience.
Returns a :class:`mopidy.models.Track` or :class:`None`.
"""
tl_track = self.get_current_tl_track()
if tl_track is not None:
return tl_track.track
current_track = deprecation.deprecated_property(get_current_track)
"""
.. deprecated:: 1.0
Use :meth:`get_current_track` instead.
"""
def get_stream_title(self):
"""Get the current stream title or :class:`None`."""
return self._stream_title
def get_state(self):
"""Get The playback state."""
return self._state
def set_state(self, new_state):
(old_state, self._state) = (self.state, new_state)
logger.debug('Changing state: %s -> %s', old_state, new_state)
"""Set the playback state.
self._trigger_playback_state_changed(old_state, new_state)
state = property(get_state, set_state)
"""
The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or
:attr:`STOPPED`.
Must be :attr:`PLAYING`, :attr:`PAUSED`, or :attr:`STOPPED`.
Possible states and transitions:
@ -82,93 +100,125 @@ class PlaybackController(object):
"PAUSED" -> "PLAYING" [ label="resume" ]
"PAUSED" -> "STOPPED" [ label="stop" ]
"""
(old_state, self._state) = (self.get_state(), new_state)
logger.debug('Changing state: %s -> %s', old_state, new_state)
self._trigger_playback_state_changed(old_state, new_state)
state = deprecation.deprecated_property(get_state, set_state)
"""
.. deprecated:: 1.0
Use :meth:`get_state` and :meth:`set_state` instead.
"""
def get_time_position(self):
backend = self._get_backend(self.current_tl_track)
"""Get time position in milliseconds."""
backend = self._get_backend(self.get_current_tl_track())
if backend:
return backend.playback.get_time_position().get()
else:
return 0
time_position = property(get_time_position)
"""Time position in milliseconds."""
time_position = deprecation.deprecated_property(get_time_position)
"""
.. deprecated:: 1.0
Use :meth:`get_time_position` instead.
"""
def get_volume(self):
if self.mixer:
return self.mixer.get_volume().get()
else:
# For testing
return self._volume
"""
.. deprecated:: 1.0
Use :meth:`core.mixer.get_volume()
<mopidy.core.MixerController.get_volume>` instead.
"""
deprecation.warn('core.playback.get_volume')
return self.core.mixer.get_volume()
def set_volume(self, volume):
if self.mixer:
self.mixer.set_volume(volume)
else:
# For testing
self._volume = volume
"""
.. deprecated:: 1.0
Use :meth:`core.mixer.set_volume()
<mopidy.core.MixerController.set_volume>` instead.
"""
deprecation.warn('core.playback.set_volume')
return self.core.mixer.set_volume(volume)
volume = property(get_volume, set_volume)
"""Volume as int in range [0..100] or :class:`None` if unknown. The volume
scale is linear.
volume = deprecation.deprecated_property(get_volume, set_volume)
"""
.. deprecated:: 1.0
Use :meth:`core.mixer.get_volume()
<mopidy.core.MixerController.get_volume>` and
:meth:`core.mixer.set_volume()
<mopidy.core.MixerController.set_volume>` instead.
"""
def get_mute(self):
if self.mixer:
return self.mixer.get_mute().get()
else:
# For testing
return self._mute
"""
.. deprecated:: 1.0
Use :meth:`core.mixer.get_mute()
<mopidy.core.MixerController.get_mute>` instead.
"""
deprecation.warn('core.playback.get_mute')
return self.core.mixer.get_mute()
def set_mute(self, value):
value = bool(value)
if self.mixer:
self.mixer.set_mute(value)
else:
# For testing
self._mute = value
def set_mute(self, mute):
"""
.. deprecated:: 1.0
Use :meth:`core.mixer.set_mute()
<mopidy.core.MixerController.set_mute>` instead.
"""
deprecation.warn('core.playback.set_mute')
return self.core.mixer.set_mute(mute)
mute = property(get_mute, set_mute)
"""Mute state as a :class:`True` if muted, :class:`False` otherwise"""
mute = deprecation.deprecated_property(get_mute, set_mute)
"""
.. deprecated:: 1.0
Use :meth:`core.mixer.get_mute()
<mopidy.core.MixerController.get_mute>` and
:meth:`core.mixer.set_mute()
<mopidy.core.MixerController.set_mute>` instead.
"""
# Methods
def on_end_of_stream(self):
self.state = PlaybackState.STOPPED
self.current_tl_track = None
def _on_end_of_stream(self):
self.set_state(PlaybackState.STOPPED)
self._set_current_tl_track(None)
# TODO: self._trigger_track_playback_ended?
def on_stream_changed(self, uri):
def _on_stream_changed(self, uri):
self._stream_title = None
if self._pending_tl_track:
self.current_tl_track = self._pending_tl_track
self._set_current_tl_track(self._pending_tl_track)
self._pending_tl_track = None
self._trigger_track_playback_started()
def on_about_to_finish(self):
def _on_about_to_finish(self):
# TODO: check that we always have a current track
original_tl_track = self.current_tl_track
original_tl_track = self.get_current_tl_track()
next_tl_track = self.core.tracklist.eot_track(original_tl_track)
# TODO: only set pending if we have a backend that can play it?
# TODO: skip tracks that don't have a backend?
self._pending_tl_track = next_tl_track
backend = self._get_backend(next_tl_track)
if backend:
backend.playback.change_track(next_tl_track.track).get()
self.core.tracklist.mark_played(original_tl_track)
self.core.tracklist._mark_played(original_tl_track)
def on_tracklist_change(self):
def _on_tracklist_change(self):
"""
Tell the playback controller that the current playlist has changed.
Used by :class:`mopidy.core.TracklistController`.
"""
if not self.core.tracklist.tl_tracks:
self.stop()
self.current_tl_track = None
elif self.current_tl_track not in self.core.tracklist.tl_tracks:
self.current_tl_track = None
self._set_current_tl_track(None)
elif self.get_current_tl_track() not in self.core.tracklist.tl_tracks:
self._set_current_tl_track(None)
def next(self):
"""
@ -177,64 +227,61 @@ class PlaybackController(object):
The current playback state will be kept. If it was playing, playing
will continue. If it was paused, it will still be paused, etc.
"""
original_tl_track = self.current_tl_track
original_tl_track = self.get_current_tl_track()
next_tl_track = self.core.tracklist.next_track(original_tl_track)
backend = self._get_backend(next_tl_track)
self.current_tl_track = next_tl_track
self._set_current_tl_track(next_tl_track)
if backend:
backend.playback.prepare_change()
backend.playback.change_track(next_tl_track.track)
if self.state == PlaybackState.PLAYING:
if self.get_state() == PlaybackState.PLAYING:
result = backend.playback.play().get()
elif self.state == PlaybackState.PAUSED:
elif self.get_state() == PlaybackState.PAUSED:
result = backend.playback.pause().get()
else:
result = True
if result and self.state != PlaybackState.PAUSED:
if result and self.get_state() != PlaybackState.PAUSED:
self._trigger_track_playback_started()
elif not result:
self.core.tracklist.mark_unplayable(next_tl_track)
self.core.tracklist._mark_unplayable(next_tl_track)
# TODO: can cause an endless loop for single track repeat.
self.next()
else:
self.stop()
self.core.tracklist.mark_played(original_tl_track)
self.core.tracklist._mark_played(original_tl_track)
def pause(self):
"""Pause playback."""
backend = self._get_backend(self.current_tl_track)
backend = self._get_backend(self.get_current_tl_track())
if not backend or backend.playback.pause().get():
# TODO: switch to:
# backend.track(pause)
# wait for state change?
self.state = PlaybackState.PAUSED
self.set_state(PlaybackState.PAUSED)
self._trigger_track_playback_paused()
def play(self, tl_track=None, on_error_step=1):
def play(self, tl_track=None):
"""
Play the given track, or if the given track is :class:`None`, play the
currently active track.
:param tl_track: track to play
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:param on_error_step: direction to step at play error, 1 for next
track (default), -1 for previous track. **INTERNAL**
:type on_error_step: int, -1 or 1
"""
self._play(tl_track, on_error_step=1)
assert on_error_step in (-1, 1)
def _play(self, tl_track=None, on_error_step=1):
if tl_track is None:
if self.state == PlaybackState.PAUSED:
if self.get_state() == PlaybackState.PAUSED:
return self.resume()
if self.current_tl_track is not None:
tl_track = self.current_tl_track
if self.get_current_tl_track() is not None:
tl_track = self.get_current_tl_track()
else:
if on_error_step == 1:
tl_track = self.core.tracklist.next_track(tl_track)
@ -244,29 +291,37 @@ class PlaybackController(object):
if tl_track is None:
return
assert tl_track in self.core.tracklist.tl_tracks
assert tl_track in self.core.tracklist.get_tl_tracks()
# TODO: switch to:
# backend.play(track)
# wait for state change?
if self.state == PlaybackState.PLAYING:
if self.get_state() == PlaybackState.PLAYING:
self.stop()
self.current_tl_track = tl_track
self.state = PlaybackState.PLAYING
backend = self._get_backend(self.current_tl_track)
self._set_current_tl_track(tl_track)
self.set_state(PlaybackState.PLAYING)
backend = self._get_backend(tl_track)
success = False
if backend:
backend.playback.prepare_change()
backend.playback.change_track(tl_track.track)
success = backend.playback.play().get()
try:
success = (
backend.playback.change_track(tl_track.track).get() and
backend.playback.play().get())
except TypeError:
logger.error('%s needs to be updated to work with this '
'version of Mopidy.', backend)
if success:
self.core.tracklist._mark_playing(tl_track)
self.core.history._add_track(tl_track.track)
# TODO: replace with stream-changed
self._trigger_track_playback_started()
else:
self.core.tracklist.mark_unplayable(tl_track)
self.core.tracklist._mark_unplayable(tl_track)
if on_error_step == 1:
# TODO: can cause an endless loop for single track repeat.
self.next()
@ -280,35 +335,38 @@ class PlaybackController(object):
The current playback state will be kept. If it was playing, playing
will continue. If it was paused, it will still be paused, etc.
"""
original_tl_track = self.current_tl_track
original_tl_track = self.get_current_tl_track()
prev_tl_track = self.core.tracklist.previous_track(original_tl_track)
backend = self._get_backend(prev_tl_track)
self.current_tl_track = prev_tl_track
self._set_current_tl_track(prev_tl_track)
if backend:
backend.playback.prepare_change()
# TODO: check return values of change track
backend.playback.change_track(prev_tl_track.track)
if self.state == PlaybackState.PLAYING:
if self.get_state() == PlaybackState.PLAYING:
result = backend.playback.play().get()
elif self.state == PlaybackState.PAUSED:
elif self.get_state() == PlaybackState.PAUSED:
result = backend.playback.pause().get()
else:
result = True
if result and self.state != PlaybackState.PAUSED:
if result and self.get_state() != PlaybackState.PAUSED:
self._trigger_track_playback_started()
elif not result:
self.core.tracklist.mark_unplayable(prev_tl_track)
self.core.tracklist._mark_unplayable(prev_tl_track)
self.previous()
# TODO: no return value?
def resume(self):
"""If paused, resume playing the current track."""
if self.state != PlaybackState.PAUSED:
if self.get_state() != PlaybackState.PAUSED:
return
backend = self._get_backend(self.current_tl_track)
backend = self._get_backend(self.get_current_tl_track())
if backend and backend.playback.resume().get():
self.state = PlaybackState.PLAYING
self.set_state(PlaybackState.PLAYING)
# TODO: trigger via gst messages
self._trigger_track_playback_resumed()
# TODO: switch to:
@ -327,18 +385,20 @@ class PlaybackController(object):
if not self.core.tracklist.tracks:
return False
if self.state == PlaybackState.STOPPED:
if self.current_track and self.current_track.length is None:
return False
if self.get_state() == PlaybackState.STOPPED:
self.play()
elif self.state == PlaybackState.PAUSED:
self.resume()
if time_position < 0:
time_position = 0
elif time_position > self.current_track.length:
# TODO: gstreamer will trigger a about to finish for us, use that?
self.next()
return True
backend = self._get_backend(self.current_tl_track)
backend = self._get_backend(self.get_current_tl_track())
if not backend:
return False
@ -349,11 +409,11 @@ class PlaybackController(object):
def stop(self):
"""Stop playing."""
if self.state != PlaybackState.STOPPED:
backend = self._get_backend(self.current_tl_track)
time_position_before_stop = self.time_position
if self.get_state() != PlaybackState.STOPPED:
backend = self._get_backend(self.get_current_tl_track())
time_position_before_stop = self.get_time_position()
if not backend or backend.playback.stop().get():
self.state = PlaybackState.STOPPED
self.set_state(PlaybackState.STOPPED)
self._trigger_track_playback_ended(time_position_before_stop)
def _trigger_track_playback_paused(self):
@ -362,7 +422,8 @@ class PlaybackController(object):
return
listener.CoreListener.send(
'track_playback_paused',
tl_track=self.current_tl_track, time_position=self.time_position)
tl_track=self.get_current_tl_track(),
time_position=self.get_time_position())
def _trigger_track_playback_resumed(self):
logger.debug('Triggering track playback resumed event')
@ -370,27 +431,27 @@ class PlaybackController(object):
return
listener.CoreListener.send(
'track_playback_resumed',
tl_track=self.current_tl_track, time_position=self.time_position)
tl_track=self.get_current_tl_track(),
time_position=self.get_time_position())
def _trigger_track_playback_started(self):
# TODO: replace with stream-changed
logger.debug('Triggering track playback started event')
if self.current_tl_track is None:
if self.get_current_tl_track() is None:
return
self.core.tracklist.mark_playing(self.current_tl_track)
self.core.history.add(self.current_tl_track.track)
listener.CoreListener.send(
'track_playback_started',
tl_track=self.current_tl_track)
tl_track = self.get_current_tl_track()
self.core.tracklist._mark_playing(tl_track)
self.core.history._add_track(tl_track.track)
listener.CoreListener.send('track_playback_started', tl_track=tl_track)
def _trigger_track_playback_ended(self, time_position_before_stop):
logger.debug('Triggering track playback ended event')
if self.current_tl_track is None:
if self.get_current_tl_track() is None:
return
listener.CoreListener.send(
'track_playback_ended',
tl_track=self.current_tl_track,
tl_track=self.get_current_tl_track(),
time_position=time_position_before_stop)
def _trigger_playback_state_changed(self, old_state, new_state):

View File

@ -1,11 +1,15 @@
from __future__ import absolute_import, unicode_literals
import itertools
import logging
import urlparse
import pykka
from . import listener
from mopidy.core import listener
from mopidy.models import Playlist
from mopidy.utils import deprecation
logger = logging.getLogger(__name__)
class PlaylistsController(object):
@ -15,20 +19,85 @@ class PlaylistsController(object):
self.backends = backends
self.core = core
def get_playlists(self, include_tracks=True):
futures = [b.playlists.playlists
for b in self.backends.with_playlists.values()]
results = pykka.get_all(futures)
playlists = list(itertools.chain(*results))
if not include_tracks:
playlists = [p.copy(tracks=[]) for p in playlists]
return playlists
playlists = property(get_playlists)
def as_list(self):
"""
The available playlists.
Get a list of the currently available playlists.
Read-only. List of :class:`mopidy.models.Playlist`.
Returns a list of :class:`~mopidy.models.Ref` objects referring to the
playlists. In other words, no information about the playlists' content
is given.
:rtype: list of :class:`mopidy.models.Ref`
.. versionadded:: 1.0
"""
futures = {
b.actor_ref.actor_class.__name__: b.playlists.as_list()
for b in set(self.backends.with_playlists.values())}
results = []
for backend_name, future in futures.items():
try:
results.extend(future.get())
except NotImplementedError:
logger.warning(
'%s does not implement playlists.as_list(). '
'Please upgrade it.', backend_name)
return results
def get_items(self, uri):
"""
Get the items in a playlist specified by ``uri``.
Returns a list of :class:`~mopidy.models.Ref` objects referring to the
playlist's items.
If a playlist with the given ``uri`` doesn't exist, it returns
:class:`None`.
:rtype: list of :class:`mopidy.models.Ref`, or :class:`None`
.. versionadded:: 1.0
"""
uri_scheme = urlparse.urlparse(uri).scheme
backend = self.backends.with_playlists.get(uri_scheme, None)
if backend:
return backend.playlists.get_items(uri).get()
def get_playlists(self, include_tracks=True):
"""
Get the available playlists.
:rtype: list of :class:`mopidy.models.Playlist`
.. versionchanged:: 1.0
If you call the method with ``include_tracks=False``, the
:attr:`~mopidy.models.Playlist.last_modified` field of the returned
playlists is no longer set.
.. deprecated:: 1.0
Use :meth:`as_list` and :meth:`get_items` instead.
"""
deprecation.warn('core.playlists.get_playlists')
playlist_refs = self.as_list()
if include_tracks:
playlists = {r.uri: self.lookup(r.uri) for r in playlist_refs}
# Use the playlist name from as_list() because it knows about any
# playlist folder hierarchy, which lookup() does not.
return [
playlists[r.uri].copy(name=r.name)
for r in playlist_refs if playlists[r.uri] is not None]
else:
return [
Playlist(uri=r.uri, name=r.name) for r in playlist_refs]
playlists = deprecation.deprecated_property(get_playlists)
"""
.. deprecated:: 1.0
Use :meth:`as_list` and :meth:`get_items` instead.
"""
def create(self, name, uri_scheme=None):
@ -40,7 +109,7 @@ class PlaylistsController(object):
:class:`None` or doesn't match a current backend, the first backend is
asked to create the playlist.
All new playlists should be created by calling this method, and **not**
All new playlists must be created by calling this method, and **not**
by creating new instances of :class:`mopidy.models.Playlist`.
:param name: name of the new playlist
@ -94,7 +163,12 @@ class PlaylistsController(object):
:param criteria: one or more criteria to match by
:type criteria: dict
:rtype: list of :class:`mopidy.models.Playlist`
.. deprecated:: 1.0
Use :meth:`as_list` and filter yourself.
"""
deprecation.warn('core.playlists.filter')
criteria = criteria or kwargs
matches = self.playlists
for (key, value) in criteria.iteritems():
@ -145,14 +219,14 @@ class PlaylistsController(object):
Save the playlist.
For a playlist to be saveable, it must have the ``uri`` attribute set.
You should not set the ``uri`` atribute yourself, but use playlist
You must not set the ``uri`` atribute yourself, but use playlist
objects returned by :meth:`create` or retrieved from :attr:`playlists`,
which will always give you saveable playlists.
The method returns the saved playlist. The return playlist may differ
from the saved playlist. E.g. if the playlist name was changed, the
returned playlist may have a different URI. The caller of this method
should throw away the playlist sent to this method, and use the
must throw away the playlist sent to this method, and use the
returned playlist instead.
If the playlist's URI isn't set or doesn't match the URI scheme of a

View File

@ -7,7 +7,7 @@ import random
from mopidy import compat
from mopidy.core import listener
from mopidy.models import TlTrack
from mopidy.utils import deprecation
logger = logging.getLogger(__name__)
@ -26,114 +26,176 @@ class TracklistController(object):
# Properties
def get_tl_tracks(self):
"""Get tracklist as list of :class:`mopidy.models.TlTrack`."""
return self._tl_tracks[:]
tl_tracks = property(get_tl_tracks)
tl_tracks = deprecation.deprecated_property(get_tl_tracks)
"""
List of :class:`mopidy.models.TlTrack`.
Read-only.
.. deprecated:: 1.0
Use :meth:`get_tl_tracks` instead.
"""
def get_tracks(self):
"""Get tracklist as list of :class:`mopidy.models.Track`."""
return [tl_track.track for tl_track in self._tl_tracks]
tracks = property(get_tracks)
tracks = deprecation.deprecated_property(get_tracks)
"""
List of :class:`mopidy.models.Track` in the tracklist.
Read-only.
.. deprecated:: 1.0
Use :meth:`get_tracks` instead.
"""
def get_length(self):
"""Get length of the tracklist."""
return len(self._tl_tracks)
length = property(get_length)
"""Length of the tracklist."""
length = deprecation.deprecated_property(get_length)
"""
.. deprecated:: 1.0
Use :meth:`get_length` instead.
"""
def get_version(self):
"""
Get the tracklist version.
Integer which is increased every time the tracklist is changed. Is not
reset before Mopidy is restarted.
"""
return self._version
def _increase_version(self):
self._version += 1
self.core.playback.on_tracklist_change()
self.core.playback._on_tracklist_change()
self._trigger_tracklist_changed()
version = property(get_version)
version = deprecation.deprecated_property(get_version)
"""
The tracklist version.
Read-only. Integer which is increased every time the tracklist is changed.
Is not reset before Mopidy is restarted.
.. deprecated:: 1.0
Use :meth:`get_version` instead.
"""
def get_consume(self):
return getattr(self, '_consume', False)
"""Get consume mode.
def set_consume(self, value):
if self.get_consume() != value:
self._trigger_options_changed()
return setattr(self, '_consume', value)
consume = property(get_consume, set_consume)
"""
:class:`True`
Tracks are removed from the tracklist when they have been played.
:class:`False`
Tracks are not removed from the tracklist.
"""
return getattr(self, '_consume', False)
def set_consume(self, value):
"""Set consume mode.
:class:`True`
Tracks are removed from the tracklist when they have been played.
:class:`False`
Tracks are not removed from the tracklist.
"""
if self.get_consume() != value:
self._trigger_options_changed()
return setattr(self, '_consume', value)
consume = deprecation.deprecated_property(get_consume, set_consume)
"""
.. deprecated:: 1.0
Use :meth:`get_consume` and :meth:`set_consume` instead.
"""
def get_random(self):
"""Get random mode.
:class:`True`
Tracks are selected at random from the tracklist.
:class:`False`
Tracks are played in the order of the tracklist.
"""
return getattr(self, '_random', False)
def set_random(self, value):
if self.get_random() != value:
self._trigger_options_changed()
if value:
self._shuffled = self.tl_tracks
random.shuffle(self._shuffled)
return setattr(self, '_random', value)
"""Set random mode.
random = property(get_random, set_random)
"""
:class:`True`
Tracks are selected at random from the tracklist.
:class:`False`
Tracks are played in the order of the tracklist.
"""
if self.get_random() != value:
self._trigger_options_changed()
if value:
self._shuffled = self.get_tl_tracks()
random.shuffle(self._shuffled)
return setattr(self, '_random', value)
random = deprecation.deprecated_property(get_random, set_random)
"""
.. deprecated:: 1.0
Use :meth:`get_random` and :meth:`set_random` instead.
"""
def get_repeat(self):
"""
Get repeat mode.
:class:`True`
The tracklist is played repeatedly.
:class:`False`
The tracklist is played once.
"""
return getattr(self, '_repeat', False)
def set_repeat(self, value):
if self.get_repeat() != value:
self._trigger_options_changed()
return setattr(self, '_repeat', value)
repeat = property(get_repeat, set_repeat)
"""
Set repeat mode.
To repeat a single track, set both ``repeat`` and ``single``.
:class:`True`
The tracklist is played repeatedly. To repeat a single track, select
both :attr:`repeat` and :attr:`single`.
The tracklist is played repeatedly.
:class:`False`
The tracklist is played once.
"""
if self.get_repeat() != value:
self._trigger_options_changed()
return setattr(self, '_repeat', value)
repeat = deprecation.deprecated_property(get_repeat, set_repeat)
"""
.. deprecated:: 1.0
Use :meth:`get_repeat` and :meth:`set_repeat` instead.
"""
def get_single(self):
"""
Get single mode.
:class:`True`
Playback is stopped after current song, unless in ``repeat`` mode.
:class:`False`
Playback continues after current song.
"""
return getattr(self, '_single', False)
def set_single(self, value):
"""
Set single mode.
:class:`True`
Playback is stopped after current song, unless in ``repeat`` mode.
:class:`False`
Playback continues after current song.
"""
if self.get_single() != value:
self._trigger_options_changed()
return setattr(self, '_single', value)
single = property(get_single, set_single)
single = deprecation.deprecated_property(get_single, set_single)
"""
:class:`True`
Playback is stopped after current song, unless in :attr:`repeat`
mode.
:class:`False`
Playback continues after current song.
.. deprecated:: 1.0
Use :meth:`get_single` and :meth:`set_single` instead.
"""
# Methods
@ -161,9 +223,9 @@ class TracklistController(object):
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
"""
if self.single and self.repeat:
if self.get_single() and self.get_repeat():
return tl_track
elif self.single:
elif self.get_single():
return None
# Current difference between next and EOT handling is that EOT needs to
@ -186,30 +248,30 @@ class TracklistController(object):
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
"""
if not self.tl_tracks:
if not self.get_tl_tracks():
return None
if self.random and not self._shuffled:
if self.repeat or not tl_track:
if self.get_random() and not self._shuffled:
if self.get_repeat() or not tl_track:
logger.debug('Shuffling tracks')
self._shuffled = self.tl_tracks
self._shuffled = self.get_tl_tracks()
random.shuffle(self._shuffled)
if self.random:
if self.get_random():
try:
return self._shuffled[0]
except IndexError:
return None
if tl_track is None:
return self.tl_tracks[0]
return self.get_tl_tracks()[0]
next_index = self.index(tl_track) + 1
if self.repeat:
next_index %= len(self.tl_tracks)
if self.get_repeat():
next_index %= len(self.get_tl_tracks())
try:
return self.tl_tracks[next_index]
return self.get_tl_tracks()[next_index]
except IndexError:
return None
@ -226,7 +288,7 @@ class TracklistController(object):
:type tl_track: :class:`mopidy.models.TlTrack` or :class:`None`
:rtype: :class:`mopidy.models.TlTrack` or :class:`None`
"""
if self.repeat or self.consume or self.random:
if self.get_repeat() or self.get_consume() or self.get_random():
return tl_track
position = self.index(tl_track)
@ -234,15 +296,18 @@ class TracklistController(object):
if position in (None, 0):
return None
return self.tl_tracks[position - 1]
return self.get_tl_tracks()[position - 1]
def add(self, tracks=None, at_position=None, uri=None):
def add(self, tracks=None, at_position=None, uri=None, uris=None):
"""
Add the track or list of tracks to the tracklist.
If ``uri`` is given instead of ``tracks``, the URI is looked up in the
library and the resulting tracks are added to the tracklist.
If ``uris`` is given instead of ``tracks``, the URIs are looked up in
the library and the resulting tracks are added to the tracklist.
If ``at_position`` is given, the tracks placed at the given position in
the tracklist. If ``at_position`` is not given, the tracks are appended
to the end of the tracklist.
@ -256,12 +321,32 @@ class TracklistController(object):
:param uri: URI for tracks to add
:type uri: string
:rtype: list of :class:`mopidy.models.TlTrack`
"""
assert tracks is not None or uri is not None, \
'tracks or uri must be provided'
if tracks is None and uri is not None:
tracks = self.core.library.lookup(uri)
.. versionadded:: 1.0
The ``uris`` argument.
.. deprecated:: 1.0
The ``tracks`` and ``uri`` arguments. Use ``uris``.
"""
assert tracks is not None or uri is not None or uris is not None, \
'tracks, uri or uris must be provided'
# TODO: assert that tracks are track instances
if tracks:
deprecation.warn('core.tracklist.add:tracks_arg')
if uri:
deprecation.warn('core.tracklist.add:uri_arg')
if tracks is None:
if uri is not None:
uris = [uri]
tracks = []
track_map = self.core.library.lookup(uris=uris)
for uri in uris:
tracks.extend(track_map[uri])
tl_tracks = []
@ -329,8 +414,8 @@ class TracklistController(object):
criteria = criteria or kwargs
matches = self._tl_tracks
for (key, values) in criteria.items():
if (not isinstance(values, collections.Iterable)
or isinstance(values, compat.string_types)):
if (not isinstance(values, collections.Iterable) or
isinstance(values, compat.string_types)):
# Fail hard if anyone is using the <0.17 calling style
raise ValueError('Filter values must be iterable: %r' % values)
if key == 'tlid':
@ -436,27 +521,27 @@ class TracklistController(object):
"""
return self._tl_tracks[start:end]
def mark_playing(self, tl_track):
"""Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**"""
if self.random and tl_track in self._shuffled:
def _mark_playing(self, tl_track):
"""Internal method for :class:`mopidy.core.PlaybackController`."""
if self.get_random() and tl_track in self._shuffled:
self._shuffled.remove(tl_track)
def mark_unplayable(self, tl_track):
"""Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**"""
def _mark_unplayable(self, tl_track):
"""Internal method for :class:`mopidy.core.PlaybackController`."""
logger.warning('Track is not playable: %s', tl_track.track.uri)
if self.random and tl_track in self._shuffled:
if self.get_random() and tl_track in self._shuffled:
self._shuffled.remove(tl_track)
def mark_played(self, tl_track):
"""Method for :class:`mopidy.core.PlaybackController`. **INTERNAL**"""
def _mark_played(self, tl_track):
"""Internal method for :class:`mopidy.core.PlaybackController`."""
if self.consume and tl_track is not None:
self.remove(tlid=[tl_track.tlid])
return True
return False
def _trigger_tracklist_changed(self):
if self.random:
self._shuffled = self.tl_tracks
if self.get_random():
self._shuffled = self.get_tl_tracks()
random.shuffle(self._shuffled)
else:
self._shuffled = []

View File

@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals
class MopidyException(Exception):
def __init__(self, message, *args, **kwargs):
super(MopidyException, self).__init__(message, *args, **kwargs)
self._message = message
@ -25,6 +26,7 @@ class ExtensionError(MopidyException):
class FindError(MopidyException):
def __init__(self, message, errno=None):
super(FindError, self).__init__(message, errno)
self.errno = errno

View File

@ -12,6 +12,7 @@ logger = logging.getLogger(__name__)
class Extension(object):
"""Base class for Mopidy extensions"""
dist_name = None
@ -104,6 +105,7 @@ class Extension(object):
class Registry(collections.Mapping):
"""Registry of components provided by Mopidy extensions.
Passed to the :meth:`~Extension.setup` method of all extensions. The

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -10,7 +10,7 @@ import tornado.websocket
import mopidy
from mopidy import core, models
from mopidy.utils import jsonrpc
from mopidy.utils import encoding, jsonrpc
logger = logging.getLogger(__name__)
@ -43,6 +43,7 @@ def make_jsonrpc_wrapper(core_actor):
'core.get_version': core.Core.get_version,
'core.history': core.HistoryController,
'core.library': core.LibraryController,
'core.mixer': core.MixerController,
'core.playback': core.PlaybackController,
'core.playlists': core.PlaylistsController,
'core.tracklist': core.TracklistController,
@ -54,6 +55,7 @@ def make_jsonrpc_wrapper(core_actor):
'core.get_version': core_actor.get_version,
'core.history': core_actor.history,
'core.library': core_actor.library,
'core.mixer': core_actor.mixer,
'core.playback': core_actor.playback,
'core.playlists': core_actor.playlists,
'core.tracklist': core_actor.tracklist,
@ -73,7 +75,16 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
@classmethod
def broadcast(cls, msg):
for client in cls.clients:
# We could check for client.ws_connection, but we don't really
# care why the broadcast failed, we just want the rest of them
# to succeed, so catch everything.
try:
client.write_message(msg)
except Exception as e:
error_msg = encoding.locale_decode(e)
logger.debug('Broadcast of WebSocket message to %s failed: %s',
client.request.remote_ip, error_msg)
# TODO: should this do the same cleanup as the on_message code?
def initialize(self, core):
self.jsonrpc = make_jsonrpc_wrapper(core)
@ -111,7 +122,8 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
'Sent WebSocket message to %s: %r',
self.request.remote_ip, response)
except Exception as e:
logger.error('WebSocket request error: %s', e)
error_msg = encoding.locale_decode(e)
logger.error('WebSocket request error: %s', error_msg)
if self.ws_connection:
# Tornado 3.2+ checks if self.ws_connection is None before
# using it, but not older versions.
@ -130,6 +142,7 @@ def set_mopidy_headers(request_handler):
class JsonRpcHandler(tornado.web.RequestHandler):
def initialize(self, core):
self.jsonrpc = make_jsonrpc_wrapper(core)
@ -164,6 +177,7 @@ class JsonRpcHandler(tornado.web.RequestHandler):
class ClientListHandler(tornado.web.RequestHandler):
def initialize(self, apps, statics):
self.apps = apps
self.statics = statics
@ -185,6 +199,7 @@ class ClientListHandler(tornado.web.RequestHandler):
class StaticFileHandler(tornado.web.StaticFileHandler):
def set_extra_headers(self, path):
set_mopidy_headers(self)

View File

@ -17,10 +17,25 @@ def send(cls, event, **kwargs):
listeners = pykka.ActorRegistry.get_by_class(cls)
logger.debug('Sending %s to %s: %s', event, cls.__name__, kwargs)
for listener in listeners:
listener.proxy().on_event(event, **kwargs)
# Save time by calling methods on Pykka actor without creating a
# throwaway actor proxy.
#
# Because we use `.tell()` there is no return channel for any errors,
# so Pykka logs them immediately. The alternative would be to use
# `.ask()` and `.get()` the returned futures to block for the listeners
# to react and return their exceptions to us. Since emitting events in
# practise is making calls upwards in the stack, blocking here would
# quickly deadlock.
listener.tell({
'command': 'pykka_call',
'attr_path': ('on_event',),
'args': (event,),
'kwargs': kwargs,
})
class Listener(object):
def on_event(self, event, **kwargs):
"""
Called on all events.

View File

@ -4,7 +4,7 @@ import logging
import os
import mopidy
from mopidy import config, ext
from mopidy import config, ext, models
logger = logging.getLogger(__name__)
@ -24,7 +24,7 @@ class Extension(ext.Extension):
schema['library'] = config.String()
schema['media_dir'] = config.Path()
schema['data_dir'] = config.Path()
schema['playlists_dir'] = config.Path()
schema['playlists_dir'] = config.Deprecated()
schema['tag_cache_file'] = config.Deprecated()
schema['scan_timeout'] = config.Integer(
minimum=1000, maximum=1000 * 60 * 60)
@ -48,6 +48,7 @@ class Extension(ext.Extension):
class Library(object):
"""
Local library interface.
@ -70,6 +71,10 @@ class Library(object):
#: Name of the local library implementation, must be overriden.
name = None
#: Feature marker to indicate that you want :meth:`add()` calls to be
#: called with optional arguments tags and duration.
add_supports_tags_and_duration = False
def __init__(self, config):
self._config = config
@ -85,6 +90,43 @@ class Library(object):
"""
raise NotImplementedError
def get_distinct(self, field, query=None):
"""
List distinct values for a given field from the library.
:param string field: One of ``artist``, ``albumartist``, ``album``,
``composer``, ``performer``, ``date``or ``genre``.
:param dict query: Query to use for limiting results, see
:meth:`search` for details about the query format.
:rtype: set of values corresponding to the requested field type.
"""
return set()
def get_images(self, uris):
"""
Lookup the images for the given URIs.
The default implementation will simply call :meth:`lookup` and
try and use the album art for any tracks returned. Most local
libraries should replace this with something smarter or simply
return an empty dictionary.
:param list uris: list of URIs to find images for
:rtype: {uri: tuple of :class:`mopidy.models.Image`}
"""
result = {}
for uri in uris:
image_uris = set()
tracks = self.lookup(uri)
# local libraries may return single track
if isinstance(tracks, models.Track):
tracks = [tracks]
for track in tracks:
if track.album and track.album.images:
image_uris.update(track.album.images)
result[uri] = [models.Image(uri=u) for u in image_uris]
return result
def load(self):
"""
(Re)load any tracks stored in memory, if any, otherwise just return
@ -135,12 +177,19 @@ class Library(object):
"""
raise NotImplementedError
def add(self, track):
def add(self, track, tags=None, duration=None):
"""
Add the given track to library.
Add the given track to library. Optional args will only be added if
:attr:`add_supports_tags_and_duration` has been set.
:param track: Track to add to the library
:type track: :class:`~mopidy.models.Track`
:param tags: All the tags the scanner found for the media. See
:mod:`mopidy.audio.utils` for details about the tags.
:type tags: dictionary of tag keys with a list of values.
:param duration: Duration of media in milliseconds or :class:`None` if
unknown
:type duration: :class:`int` or :class:`None`
"""
raise NotImplementedError

View File

@ -8,7 +8,6 @@ from mopidy import backend
from mopidy.local import storage
from mopidy.local.library import LocalLibraryProvider
from mopidy.local.playback import LocalPlaybackProvider
from mopidy.local.playlists import LocalPlaylistsProvider
logger = logging.getLogger(__name__)
@ -36,5 +35,4 @@ class LocalBackend(pykka.ThreadingActor, backend.Backend):
logger.warning('Local library %s not found', library_name)
self.playback = LocalPlaybackProvider(audio=audio, backend=self)
self.playlists = LocalPlaylistsProvider(backend=self)
self.library = LocalLibraryProvider(backend=self, library=library)

View File

@ -29,6 +29,7 @@ def _get_library(args, config):
class LocalCommand(commands.Command):
def __init__(self):
super(LocalCommand, self).__init__()
self.add_child('scan', ScanCommand())
@ -61,7 +62,10 @@ class ScanCommand(commands.Command):
super(ScanCommand, self).__init__()
self.add_argument('--limit',
action='store', type=int, dest='limit', default=None,
help='Maxmimum number of tracks to scan')
help='Maximum number of tracks to scan')
self.add_argument('--force',
action='store_true', dest='force', default=False,
help='Force rescan of all media files')
def run(self, args, config):
media_dir = config['local']['media_dir']
@ -97,7 +101,7 @@ class ScanCommand(commands.Command):
if mtime is None:
logger.debug('Missing file %s', track.uri)
uris_to_remove.add(track.uri)
elif mtime > track.last_modified:
elif mtime > track.last_modified or args.force:
uris_to_update.add(track.uri)
uris_in_library.add(track.uri)
@ -130,7 +134,8 @@ class ScanCommand(commands.Command):
try:
relpath = translator.local_track_uri_to_path(uri, media_dir)
file_uri = path.path_to_uri(os.path.join(media_dir, relpath))
tags, duration = scanner.scan(file_uri)
result = scanner.scan(file_uri)
tags, duration = result.tags, result.duration
if duration < MIN_DURATION_MS:
logger.warning('Failed %s: Track shorter than %dms',
uri, MIN_DURATION_MS)
@ -138,8 +143,9 @@ class ScanCommand(commands.Command):
mtime = file_mtimes.get(os.path.join(media_dir, relpath))
track = utils.convert_tags_to_track(tags).copy(
uri=uri, length=duration, last_modified=mtime)
track = translator.add_musicbrainz_coverart_to_track(track)
# TODO: add tags to call if library supports it.
if library.add_supports_tags_and_duration:
library.add(track, tags=tags, duration=duration)
else:
library.add(track)
logger.debug('Added %s', track.uri)
except exceptions.ScannerError as error:
@ -157,6 +163,7 @@ class ScanCommand(commands.Command):
class _Progress(object):
def __init__(self, batch_size, total):
self.count = 0
self.batch_size = batch_size
@ -173,6 +180,6 @@ class _Progress(object):
logger.info('Scanned %d of %d files in %ds.',
self.count, self.total, duration)
else:
remainder = duration // self.count * (self.total - self.count)
remainder = duration / self.count * (self.total - self.count)
logger.info('Scanned %d of %d files in %ds, ~%ds left.',
self.count, self.total, duration, remainder)

View File

@ -3,7 +3,6 @@ enabled = true
library = json
media_dir = $XDG_MUSIC_DIR
data_dir = $XDG_DATA_DIR/mopidy/local
playlists_dir = $XDG_DATA_DIR/mopidy/local/playlists
scan_timeout = 1000
scan_flush_threshold = 1000
scan_follow_symlinks = false

View File

@ -8,12 +8,11 @@ import os
import re
import sys
import tempfile
import time
import mopidy
from mopidy import compat, local, models
from mopidy.local import search, storage, translator
from mopidy.utils import encoding
from mopidy.utils import encoding, timer
logger = logging.getLogger(__name__)
@ -109,20 +108,6 @@ class _BrowseCache(object):
return self._cache.get(uri, {}).values()
# TODO: make this available to other code?
class DebugTimer(object):
def __init__(self, msg):
self.msg = msg
self.start = None
def __enter__(self):
self.start = time.time()
def __exit__(self, exc_type, exc_value, traceback):
duration = (time.time() - self.start) * 1000
logger.debug('%s: %dms', self.msg, duration)
class JsonLibrary(local.Library):
name = 'json'
@ -142,10 +127,10 @@ class JsonLibrary(local.Library):
def load(self):
logger.debug('Loading library: %s', self._json_file)
with DebugTimer('Loading tracks'):
with timer.time_logger('Loading tracks'):
library = load_library(self._json_file)
self._tracks = dict((t.uri, t) for t in library.get('tracks', []))
with DebugTimer('Building browse cache'):
with timer.time_logger('Building browse cache'):
self._browse_cache = _BrowseCache(sorted(self._tracks.keys()))
return len(self._tracks)
@ -155,13 +140,47 @@ class JsonLibrary(local.Library):
except KeyError:
return []
def get_distinct(self, field, query=None):
if field == 'artist':
def distinct(track):
return {a.name for a in track.artists}
elif field == 'albumartist':
def distinct(track):
album = track.album or models.Album()
return {a.name for a in album.artists}
elif field == 'album':
def distinct(track):
album = track.album or models.Album()
return {album.name}
elif field == 'composer':
def distinct(track):
return {a.name for a in track.composers}
elif field == 'performer':
def distinct(track):
return {a.name for a in track.performers}
elif field == 'date':
def distinct(track):
return {track.date}
elif field == 'genre':
def distinct(track):
return {track.genre}
else:
return set()
distinct_result = set()
search_result = search.search(self._tracks.values(), query, limit=None)
for track in search_result.tracks:
distinct_result.update(distinct(track))
return distinct_result
def search(self, query=None, limit=100, offset=0, uris=None, exact=False):
tracks = self._tracks.values()
# TODO: pass limit and offset into search helpers
if exact:
return search.find_exact(tracks, query=query, uris=uris)
return search.find_exact(
tracks, query=query, limit=limit, offset=offset, uris=uris)
else:
return search.search(tracks, query=query, uris=uris)
return search.search(
tracks, query=query, limit=limit, offset=offset, uris=uris)
def begin(self):
return compat.itervalues(self._tracks)

View File

@ -8,6 +8,7 @@ logger = logging.getLogger(__name__)
class LocalLibraryProvider(backend.LibraryProvider):
"""Proxy library that delegates work to our active local library."""
root_directory = models.Ref.directory(
@ -23,6 +24,16 @@ class LocalLibraryProvider(backend.LibraryProvider):
return []
return self._library.browse(uri)
def get_distinct(self, field, query=None):
if not self._library:
return set()
return self._library.get_distinct(field, query)
def get_images(self, uris):
if not self._library:
return {}
return self._library.get_images(uris)
def refresh(self, uri=None):
if not self._library:
return 0
@ -41,12 +52,7 @@ class LocalLibraryProvider(backend.LibraryProvider):
tracks = [tracks]
return tracks
def find_exact(self, query=None, uris=None):
def search(self, query=None, uris=None, exact=False):
if not self._library:
return None
return self._library.search(query=query, uris=uris, exact=True)
def search(self, query=None, uris=None):
if not self._library:
return None
return self._library.search(query=query, uris=uris, exact=False)
return self._library.search(query=query, uris=uris, exact=exact)

View File

@ -1,16 +1,11 @@
from __future__ import absolute_import, unicode_literals
import logging
from mopidy import backend
from mopidy.local import translator
logger = logging.getLogger(__name__)
class LocalPlaybackProvider(backend.PlaybackProvider):
def change_track(self, track):
track = track.copy(uri=translator.local_track_uri_to_file_uri(
track.uri, self.backend.config['local']['media_dir']))
return super(LocalPlaybackProvider, self).change_track(track)
def translate_uri(self, uri):
return translator.local_track_uri_to_file_uri(
uri, self.backend.config['local']['media_dir'])

View File

@ -1,122 +0,0 @@
from __future__ import absolute_import, division, unicode_literals
import glob
import logging
import os
import shutil
from mopidy import backend
from mopidy.models import Playlist
from mopidy.utils import formatting, path
from .translator import parse_m3u
logger = logging.getLogger(__name__)
class LocalPlaylistsProvider(backend.PlaylistsProvider):
def __init__(self, *args, **kwargs):
super(LocalPlaylistsProvider, self).__init__(*args, **kwargs)
self._media_dir = self.backend.config['local']['media_dir']
self._playlists_dir = self.backend.config['local']['playlists_dir']
self.refresh()
def create(self, name):
name = formatting.slugify(name)
uri = 'local:playlist:%s.m3u' % name
playlist = Playlist(uri=uri, name=name)
return self.save(playlist)
def delete(self, uri):
playlist = self.lookup(uri)
if not playlist:
return
self._playlists.remove(playlist)
self._delete_m3u(playlist.uri)
def lookup(self, uri):
# TODO: store as {uri: playlist}?
for playlist in self._playlists:
if playlist.uri == uri:
return playlist
def refresh(self):
playlists = []
for m3u in glob.glob(os.path.join(self._playlists_dir, '*.m3u')):
name = os.path.splitext(os.path.basename(m3u))[0]
uri = 'local:playlist:%s' % name
tracks = []
for track in parse_m3u(m3u, self._media_dir):
tracks.append(track)
playlist = Playlist(uri=uri, name=name, tracks=tracks)
playlists.append(playlist)
self.playlists = playlists
# TODO: send what scheme we loaded them for?
backend.BackendListener.send('playlists_loaded')
logger.info(
'Loaded %d local playlists from %s',
len(playlists), self._playlists_dir)
def save(self, playlist):
assert playlist.uri, 'Cannot save playlist without URI'
old_playlist = self.lookup(playlist.uri)
if old_playlist and playlist.name != old_playlist.name:
playlist = playlist.copy(name=formatting.slugify(playlist.name))
playlist = self._rename_m3u(playlist)
self._save_m3u(playlist)
if old_playlist is not None:
index = self._playlists.index(old_playlist)
self._playlists[index] = playlist
else:
self._playlists.append(playlist)
return playlist
def _m3u_uri_to_path(self, uri):
# TODO: create uri handling helpers for local uri types.
file_path = path.uri_to_path(uri).split(':', 1)[1]
file_path = os.path.join(self._playlists_dir, file_path)
path.check_file_path_is_inside_base_dir(file_path, self._playlists_dir)
return file_path
def _write_m3u_extinf(self, file_handle, track):
title = track.name.encode('latin-1', 'replace')
runtime = track.length // 1000 if track.length else -1
file_handle.write('#EXTINF:' + str(runtime) + ',' + title + '\n')
def _save_m3u(self, playlist):
file_path = self._m3u_uri_to_path(playlist.uri)
extended = any(track.name for track in playlist.tracks)
with open(file_path, 'w') as file_handle:
if extended:
file_handle.write('#EXTM3U\n')
for track in playlist.tracks:
if extended and track.name:
self._write_m3u_extinf(file_handle, track)
file_handle.write(track.uri + '\n')
def _delete_m3u(self, uri):
file_path = self._m3u_uri_to_path(uri)
if os.path.exists(file_path):
os.remove(file_path)
def _rename_m3u(self, playlist):
dst_name = formatting.slugify(playlist.name)
dst_uri = 'local:playlist:%s.m3u' % dst_name
src_file_path = self._m3u_uri_to_path(playlist.uri)
dst_file_path = self._m3u_uri_to_path(dst_uri)
shutil.move(src_file_path, dst_file_path)
return playlist.copy(uri=dst_uri)

View File

@ -3,7 +3,18 @@ from __future__ import absolute_import, unicode_literals
from mopidy.models import SearchResult
def find_exact(tracks, query=None, uris=None):
def find_exact(tracks, query=None, limit=100, offset=0, uris=None):
"""
Filter a list of tracks where ``field`` is ``values``.
:param list tracks: a list of :class:`~mopidy.models.Track`
:param dict query: one or more field/value pairs to search for
:param int limit: maximum number of results to return
:param int offset: offset into result set to use.
:param uris: zero or more URI roots to limit the search to
:type uris: list of strings or :class:`None`
:rtype: :class:`~mopidy.models.SearchResult`
"""
# TODO Only return results within URI roots given by ``uris``
if query is None:
@ -12,8 +23,6 @@ def find_exact(tracks, query=None, uris=None):
_validate_query(query)
for (field, values) in query.items():
if not hasattr(values, '__iter__'):
values = [values]
# FIXME this is bound to be slow for large libraries
for value in values:
if field == 'track_no':
@ -21,27 +30,42 @@ def find_exact(tracks, query=None, uris=None):
else:
q = value.strip()
uri_filter = lambda t: q == t.uri
track_name_filter = lambda t: q == t.name
album_filter = lambda t: q == getattr(
getattr(t, 'album', None), 'name', None)
artist_filter = lambda t: filter(
lambda a: q == a.name, t.artists)
albumartist_filter = lambda t: any([
q == a.name
for a in getattr(t.album, 'artists', [])])
composer_filter = lambda t: any([
q == a.name
for a in getattr(t, 'composers', [])])
performer_filter = lambda t: any([
q == a.name
for a in getattr(t, 'performers', [])])
track_no_filter = lambda t: q == t.track_no
genre_filter = lambda t: t.genre and q == t.genre
date_filter = lambda t: q == t.date
comment_filter = lambda t: q == t.comment
any_filter = lambda t: (
uri_filter(t) or
def uri_filter(t):
return q == t.uri
def track_name_filter(t):
return q == t.name
def album_filter(t):
return q == getattr(getattr(t, 'album', None), 'name', None)
def artist_filter(t):
return filter(lambda a: q == a.name, t.artists)
def albumartist_filter(t):
return any([
q == a.name for a in getattr(t.album, 'artists', [])])
def composer_filter(t):
return any([q == a.name for a in getattr(t, 'composers', [])])
def performer_filter(t):
return any([q == a.name for a in getattr(t, 'performers', [])])
def track_no_filter(t):
return q == t.track_no
def genre_filter(t):
return (t.genre and q == t.genre)
def date_filter(t):
return q == t.date
def comment_filter(t):
return q == t.comment
def any_filter(t):
return (uri_filter(t) or
track_name_filter(t) or
album_filter(t) or
artist_filter(t) or
@ -80,11 +104,26 @@ def find_exact(tracks, query=None, uris=None):
else:
raise LookupError('Invalid lookup field: %s' % field)
if limit is None:
tracks = tracks[offset:]
else:
tracks = tracks[offset:offset + limit]
# TODO: add local:search:<query>
return SearchResult(uri='local:search', tracks=tracks)
def search(tracks, query=None, uris=None):
def search(tracks, query=None, limit=100, offset=0, uris=None):
"""
Filter a list of tracks where ``field`` is like ``values``.
:param list tracks: a list of :class:`~mopidy.models.Track`
:param dict query: one or more field/value pairs to search for
:param int limit: maximum number of results to return
:param int offset: offset into result set to use.
:param uris: zero or more URI roots to limit the search to
:type uris: list of strings or :class:`None`
:rtype: :class:`~mopidy.models.SearchResult`
"""
# TODO Only return results within URI roots given by ``uris``
if query is None:
@ -93,8 +132,6 @@ def search(tracks, query=None, uris=None):
_validate_query(query)
for (field, values) in query.items():
if not hasattr(values, '__iter__'):
values = [values]
# FIXME this is bound to be slow for large libraries
for value in values:
if field == 'track_no':
@ -102,28 +139,46 @@ def search(tracks, query=None, uris=None):
else:
q = value.strip().lower()
uri_filter = lambda t: bool(t.uri and q in t.uri.lower())
track_name_filter = lambda t: bool(t.name and q in t.name.lower())
album_filter = lambda t: bool(
t.album and t.album.name and q in t.album.name.lower())
artist_filter = lambda t: bool(filter(
def uri_filter(t):
return bool(t.uri and q in t.uri.lower())
def track_name_filter(t):
return bool(t.name and q in t.name.lower())
def album_filter(t):
return bool(t.album and t.album.name and
q in t.album.name.lower())
def artist_filter(t):
return bool(filter(
lambda a: bool(a.name and q in a.name.lower()), t.artists))
albumartist_filter = lambda t: any([
a.name and q in a.name.lower()
def albumartist_filter(t):
return any([a.name and q in a.name.lower()
for a in getattr(t.album, 'artists', [])])
composer_filter = lambda t: any([
a.name and q in a.name.lower()
def composer_filter(t):
return any([a.name and q in a.name.lower()
for a in getattr(t, 'composers', [])])
performer_filter = lambda t: any([
a.name and q in a.name.lower()
def performer_filter(t):
return any([a.name and q in a.name.lower()
for a in getattr(t, 'performers', [])])
track_no_filter = lambda t: q == t.track_no
genre_filter = lambda t: bool(t.genre and q in t.genre.lower())
date_filter = lambda t: bool(t.date and t.date.startswith(q))
comment_filter = lambda t: bool(
t.comment and q in t.comment.lower())
any_filter = lambda t: (
uri_filter(t) or
def track_no_filter(t):
return q == t.track_no
def genre_filter(t):
return bool(t.genre and q in t.genre.lower())
def date_filter(t):
return bool(t.date and t.date.startswith(q))
def comment_filter(t):
return bool(t.comment and q in t.comment.lower())
def any_filter(t):
return (uri_filter(t) or
track_name_filter(t) or
album_filter(t) or
artist_filter(t) or
@ -161,6 +216,11 @@ def search(tracks, query=None, uris=None):
tracks = filter(any_filter, tracks)
else:
raise LookupError('Invalid lookup field: %s' % field)
if limit is None:
tracks = tracks[offset:]
else:
tracks = tracks[offset:offset + limit]
# TODO: add local:search:<query>
return SearchResult(uri='local:search', tracks=tracks)

View File

@ -20,11 +20,3 @@ def check_dirs_and_files(config):
logger.warning(
'Could not create local data dir: %s',
encoding.locale_decode(error))
# TODO: replace with data dir?
try:
path.get_or_create_dir(config['local']['playlists_dir'])
except EnvironmentError as error:
logger.warning(
'Could not create local playlists dir: %s',
encoding.locale_decode(error))

View File

@ -2,30 +2,15 @@ from __future__ import absolute_import, unicode_literals
import logging
import os
import re
import urllib
import urlparse
from mopidy import compat
from mopidy.models import Track
from mopidy.utils.encoding import locale_decode
from mopidy.utils.path import path_to_uri, uri_to_path
M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)')
COVERART_BASE = 'http://coverartarchive.org/release/%s/front'
logger = logging.getLogger(__name__)
def add_musicbrainz_coverart_to_track(track):
if track.album and track.album.musicbrainz_id:
images = [COVERART_BASE % track.album.musicbrainz_id]
album = track.album.copy(images=images)
track = track.copy(album=album)
return track
def local_track_uri_to_file_uri(uri, media_dir):
return path_to_uri(local_track_uri_to_path(uri, media_dir))
@ -38,7 +23,7 @@ def local_track_uri_to_path(uri, media_dir):
def path_to_local_track_uri(relpath):
"""Convert path releative to media_dir to local track URI."""
"""Convert path relative to media_dir to local track URI."""
if isinstance(relpath, compat.text_type):
relpath = relpath.encode('utf-8')
return b'local:track:%s' % urllib.quote(relpath)
@ -49,82 +34,3 @@ def path_to_local_directory_uri(relpath):
if isinstance(relpath, compat.text_type):
relpath = relpath.encode('utf-8')
return b'local:directory:%s' % urllib.quote(relpath)
def m3u_extinf_to_track(line):
"""Convert extended M3U directive to track template."""
m = M3U_EXTINF_RE.match(line)
if not m:
logger.warning('Invalid extended M3U directive: %s', line)
return Track()
(runtime, title) = m.groups()
if int(runtime) > 0:
return Track(name=title, length=1000*int(runtime))
else:
return Track(name=title)
def parse_m3u(file_path, media_dir):
r"""
Convert M3U file list to list of tracks
Example M3U data::
# This is a comment
Alternative\Band - Song.mp3
Classical\Other Band - New Song.mp3
Stuff.mp3
D:\More Music\Foo.mp3
http://www.example.com:8000/Listen.pls
http://www.example.com/~user/Mine.mp3
Example extended M3U data::
#EXTM3U
#EXTINF:123, Sample artist - Sample title
Sample.mp3
#EXTINF:321,Example Artist - Example title
Greatest Hits\Example.ogg
#EXTINF:-1,Radio XMP
http://mp3stream.example.com:8000/
- Relative paths of songs should be with respect to location of M3U.
- Paths are normally platform specific.
- Lines starting with # are ignored, except for extended M3U directives.
- Track.name and Track.length are set from extended M3U directives.
- m3u files are latin-1.
"""
# TODO: uris as bytes
tracks = []
try:
with open(file_path) as m3u:
contents = m3u.readlines()
except IOError as error:
logger.warning('Couldn\'t open m3u: %s', locale_decode(error))
return tracks
if not contents:
return tracks
extended = contents[0].decode('latin1').startswith('#EXTM3U')
track = Track()
for line in contents:
line = line.strip().decode('latin1')
if line.startswith('#'):
if extended and line.startswith('#EXTINF'):
track = m3u_extinf_to_track(line)
continue
if urlparse.urlsplit(line).scheme:
tracks.append(track.copy(uri=line))
elif os.path.normpath(line) == os.path.abspath(line):
path = path_to_uri(line)
tracks.append(track.copy(uri=path))
else:
path = path_to_uri(os.path.join(media_dir, line))
tracks.append(track.copy(uri=path))
track = Track()
return tracks

30
mopidy/m3u/__init__.py Normal file
View File

@ -0,0 +1,30 @@
from __future__ import absolute_import, unicode_literals
import logging
import os
import mopidy
from mopidy import config, ext
logger = logging.getLogger(__name__)
class Extension(ext.Extension):
dist_name = 'Mopidy-M3U'
ext_name = 'm3u'
version = mopidy.__version__
def get_default_config(self):
conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf')
return config.read(conf_file)
def get_config_schema(self):
schema = super(Extension, self).get_config_schema()
schema['playlists_dir'] = config.Path()
return schema
def setup(self, registry):
from .actor import M3UBackend
registry.add('backend', M3UBackend)

32
mopidy/m3u/actor.py Normal file
View File

@ -0,0 +1,32 @@
from __future__ import absolute_import, unicode_literals
import logging
import pykka
from mopidy import backend
from mopidy.m3u.library import M3ULibraryProvider
from mopidy.m3u.playlists import M3UPlaylistsProvider
from mopidy.utils import encoding, path
logger = logging.getLogger(__name__)
class M3UBackend(pykka.ThreadingActor, backend.Backend):
uri_schemes = ['m3u']
def __init__(self, config, audio):
super(M3UBackend, self).__init__()
self._config = config
try:
path.get_or_create_dir(config['m3u']['playlists_dir'])
except EnvironmentError as error:
logger.warning(
'Could not create M3U playlists dir: %s',
encoding.locale_decode(error))
self.playlists = M3UPlaylistsProvider(backend=self)
self.library = M3ULibraryProvider(backend=self)

3
mopidy/m3u/ext.conf Normal file
View File

@ -0,0 +1,3 @@
[m3u]
enabled = true
playlists_dir = $XDG_DATA_DIR/mopidy/m3u

19
mopidy/m3u/library.py Normal file
View File

@ -0,0 +1,19 @@
from __future__ import absolute_import, unicode_literals
import logging
from mopidy import backend
logger = logging.getLogger(__name__)
class M3ULibraryProvider(backend.LibraryProvider):
"""Library for looking up M3U playlists."""
def __init__(self, backend):
super(M3ULibraryProvider, self).__init__(backend)
def lookup(self, uri):
# TODO Lookup tracks in M3U playlist
return []

127
mopidy/m3u/playlists.py Normal file
View File

@ -0,0 +1,127 @@
from __future__ import absolute_import, division, unicode_literals
import glob
import logging
import operator
import os
import re
import sys
from mopidy import backend
from mopidy.m3u import translator
from mopidy.models import Playlist, Ref
logger = logging.getLogger(__name__)
class M3UPlaylistsProvider(backend.PlaylistsProvider):
# TODO: currently this only handles UNIX file systems
_invalid_filename_chars = re.compile(r'[/]')
def __init__(self, *args, **kwargs):
super(M3UPlaylistsProvider, self).__init__(*args, **kwargs)
self._playlists_dir = self.backend._config['m3u']['playlists_dir']
self._playlists = {}
self.refresh()
def as_list(self):
refs = [
Ref.playlist(uri=pl.uri, name=pl.name)
for pl in self._playlists.values()]
return sorted(refs, key=operator.attrgetter('name'))
def get_items(self, uri):
playlist = self._playlists.get(uri)
if playlist is None:
return None
return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks]
def create(self, name):
playlist = self._save_m3u(Playlist(name=name))
self._playlists[playlist.uri] = playlist
logger.info('Created playlist %s', playlist.uri)
return playlist
def delete(self, uri):
if uri in self._playlists:
path = translator.playlist_uri_to_path(uri, self._playlists_dir)
if os.path.exists(path):
os.remove(path)
else:
logger.warn('Trying to delete missing playlist file %s', path)
del self._playlists[uri]
else:
logger.warn('Trying to delete unknown playlist %s', uri)
def lookup(self, uri):
return self._playlists.get(uri)
def refresh(self):
playlists = {}
encoding = sys.getfilesystemencoding()
for path in glob.glob(os.path.join(self._playlists_dir, b'*.m3u')):
relpath = os.path.basename(path)
uri = translator.path_to_playlist_uri(relpath)
name = os.path.splitext(relpath)[0].decode(encoding)
tracks = translator.parse_m3u(path)
playlists[uri] = Playlist(uri=uri, name=name, tracks=tracks)
self._playlists = playlists
logger.info(
'Loaded %d M3U playlists from %s',
len(playlists), self._playlists_dir)
def save(self, playlist):
assert playlist.uri, 'Cannot save playlist without URI'
assert playlist.uri in self._playlists, \
'Cannot save playlist with unknown URI: %s' % playlist.uri
original_uri = playlist.uri
playlist = self._save_m3u(playlist)
if playlist.uri != original_uri and original_uri in self._playlists:
self.delete(original_uri)
self._playlists[playlist.uri] = playlist
return playlist
def _write_m3u_extinf(self, file_handle, track):
title = track.name.encode('latin-1', 'replace')
runtime = track.length // 1000 if track.length else -1
file_handle.write('#EXTINF:' + str(runtime) + ',' + title + '\n')
def _sanitize_m3u_name(self, name, encoding=sys.getfilesystemencoding()):
name = self._invalid_filename_chars.sub('|', name.strip())
# make sure we end up with a valid path segment
name = name.encode(encoding, errors='replace')
name = os.path.basename(name) # paranoia?
name = name.decode(encoding)
return name
def _save_m3u(self, playlist, encoding=sys.getfilesystemencoding()):
if playlist.name:
name = self._sanitize_m3u_name(playlist.name, encoding)
uri = translator.path_to_playlist_uri(
name.encode(encoding) + b'.m3u')
path = translator.playlist_uri_to_path(uri, self._playlists_dir)
elif playlist.uri:
uri = playlist.uri
path = translator.playlist_uri_to_path(uri, self._playlists_dir)
name, _ = os.path.splitext(os.path.basename(path).decode(encoding))
else:
raise ValueError('M3U playlist needs name or URI')
extended = any(track.name for track in playlist.tracks)
with open(path, 'w') as file_handle:
if extended:
file_handle.write('#EXTM3U\n')
for track in playlist.tracks:
if extended and track.name:
self._write_m3u_extinf(file_handle, track)
file_handle.write(track.uri + '\n')
# assert playlist name matches file name/uri
return playlist.copy(uri=uri, name=name)

110
mopidy/m3u/translator.py Normal file
View File

@ -0,0 +1,110 @@
from __future__ import absolute_import, unicode_literals
import logging
import os
import re
import urllib
import urlparse
from mopidy import compat
from mopidy.models import Track
from mopidy.utils.encoding import locale_decode
from mopidy.utils.path import path_to_uri, uri_to_path
M3U_EXTINF_RE = re.compile(r'#EXTINF:(-1|\d+),(.*)')
logger = logging.getLogger(__name__)
def playlist_uri_to_path(uri, playlists_dir):
if not uri.startswith('m3u:'):
raise ValueError('Invalid URI %s' % uri)
file_path = uri_to_path(uri)
return os.path.join(playlists_dir, file_path)
def path_to_playlist_uri(relpath):
"""Convert path relative to playlists_dir to M3U URI."""
if isinstance(relpath, compat.text_type):
relpath = relpath.encode('utf-8')
return b'm3u:%s' % urllib.quote(relpath)
def m3u_extinf_to_track(line):
"""Convert extended M3U directive to track template."""
m = M3U_EXTINF_RE.match(line)
if not m:
logger.warning('Invalid extended M3U directive: %s', line)
return Track()
(runtime, title) = m.groups()
if int(runtime) > 0:
return Track(name=title, length=1000 * int(runtime))
else:
return Track(name=title)
def parse_m3u(file_path, media_dir=None):
r"""
Convert M3U file list to list of tracks
Example M3U data::
# This is a comment
Alternative\Band - Song.mp3
Classical\Other Band - New Song.mp3
Stuff.mp3
D:\More Music\Foo.mp3
http://www.example.com:8000/Listen.pls
http://www.example.com/~user/Mine.mp3
Example extended M3U data::
#EXTM3U
#EXTINF:123, Sample artist - Sample title
Sample.mp3
#EXTINF:321,Example Artist - Example title
Greatest Hits\Example.ogg
#EXTINF:-1,Radio XMP
http://mp3stream.example.com:8000/
- Relative paths of songs should be with respect to location of M3U.
- Paths are normally platform specific.
- Lines starting with # are ignored, except for extended M3U directives.
- Track.name and Track.length are set from extended M3U directives.
- m3u files are latin-1.
"""
# TODO: uris as bytes
tracks = []
try:
with open(file_path) as m3u:
contents = m3u.readlines()
except IOError as error:
logger.warning('Couldn\'t open m3u: %s', locale_decode(error))
return tracks
if not contents:
return tracks
extended = contents[0].decode('latin1').startswith('#EXTM3U')
track = Track()
for line in contents:
line = line.strip().decode('latin1')
if line.startswith('#'):
if extended and line.startswith('#EXTINF'):
track = m3u_extinf_to_track(line)
continue
if urlparse.urlsplit(line).scheme:
tracks.append(track.copy(uri=line))
elif os.path.normpath(line) == os.path.abspath(line):
path = path_to_uri(line)
tracks.append(track.copy(uri=path))
elif media_dir is not None:
path = path_to_uri(os.path.join(media_dir, line))
tracks.append(track.copy(uri=path))
track = Track()
return tracks

View File

@ -9,6 +9,7 @@ logger = logging.getLogger(__name__)
class Mixer(object):
"""
Audio mixer API
@ -111,6 +112,7 @@ class Mixer(object):
class MixerListener(listener.Listener):
"""
Marker interface for recipients of events sent by the mixer actor.

View File

@ -4,6 +4,7 @@ import json
class ImmutableObject(object):
"""
Superclass for immutable objects whose fields can only be modified via the
constructor.
@ -102,6 +103,7 @@ class ImmutableObject(object):
class ModelJSONEncoder(json.JSONEncoder):
"""
Automatically serialize Mopidy models to JSON.
@ -112,6 +114,7 @@ class ModelJSONEncoder(json.JSONEncoder):
'{"a_track": {"__model__": "Track", "name": "name"}}'
"""
def default(self, obj):
if isinstance(obj, ImmutableObject):
return obj.serialize()
@ -143,6 +146,7 @@ def model_json_decoder(dct):
class Ref(ImmutableObject):
"""
Model to represent URI references with a human friendly name and type
attached. This is intended for use a lightweight object "free" of metadata
@ -153,7 +157,7 @@ class Ref(ImmutableObject):
:param name: object name
:type name: string
:param type: object type
:type name: string
:type type: string
"""
#: The object URI. Read-only.
@ -212,7 +216,26 @@ class Ref(ImmutableObject):
return cls(**kwargs)
class Image(ImmutableObject):
"""
:param string uri: URI of the image
:param int width: Optional width of image or :class:`None`
:param int height: Optional height of image or :class:`None`
"""
#: The image URI. Read-only.
uri = None
#: Optional width of the image or :class:`None`. Read-only.
width = None
#: Optional height of the image or :class:`None`. Read-only.
height = None
class Artist(ImmutableObject):
"""
:param uri: artist URI
:type uri: string
@ -233,6 +256,7 @@ class Artist(ImmutableObject):
class Album(ImmutableObject):
"""
:param uri: album URI
:type uri: string
@ -286,6 +310,7 @@ class Album(ImmutableObject):
class Track(ImmutableObject):
"""
:param uri: track URI
:type uri: string
@ -308,7 +333,7 @@ class Track(ImmutableObject):
:param date: track release date (YYYY or YYYY-MM-DD)
:type date: string
:param length: track length in milliseconds
:type length: integer
:type length: integer or :class:`None` if there is no duration
:param bitrate: bitrate in kbit/s
:type bitrate: integer
:param comment: track comment
@ -361,13 +386,16 @@ class Track(ImmutableObject):
#: The MusicBrainz ID of the track. Read-only.
musicbrainz_id = None
#: Integer representing when the track was last modified, exact meaning
#: depends on source of track. For local files this is the mtime, for other
#: backends it could be a timestamp or simply a version counter.
#: Integer representing when the track was last modified. Exact meaning
#: depends on source of track. For local files this is the modification
#: time in milliseconds since Unix epoch. For other backends it could be an
#: equivalent timestamp or simply a version counter.
last_modified = None
def __init__(self, *args, **kwargs):
get = lambda key: frozenset(kwargs.pop(key, None) or [])
def get(key):
return frozenset(kwargs.pop(key, None) or [])
self.__dict__['artists'] = get('artists')
self.__dict__['composers'] = get('composers')
self.__dict__['performers'] = get('performers')
@ -375,6 +403,7 @@ class Track(ImmutableObject):
class TlTrack(ImmutableObject):
"""
A tracklist track. Wraps a regular track and it's tracklist ID.
@ -413,6 +442,7 @@ class TlTrack(ImmutableObject):
class Playlist(ImmutableObject):
"""
:param uri: playlist URI
:type uri: string
@ -453,6 +483,7 @@ class Playlist(ImmutableObject):
class SearchResult(ImmutableObject):
"""
:param uri: search result URI
:type uri: string

View File

@ -24,6 +24,7 @@ class Extension(ext.Extension):
schema['max_connections'] = config.Integer(minimum=1)
schema['connection_timeout'] = config.Integer(minimum=1)
schema['zeroconf'] = config.String(optional=True)
schema['command_blacklist'] = config.List(optional=True)
return schema
def validate_environment(self):

View File

@ -6,18 +6,20 @@ import pykka
from mopidy import exceptions, zeroconf
from mopidy.core import CoreListener
from mopidy.mpd import session
from mopidy.mpd import session, uri_mapper
from mopidy.utils import encoding, network, process
logger = logging.getLogger(__name__)
class MpdFrontend(pykka.ThreadingActor, CoreListener):
def __init__(self, config, core):
super(MpdFrontend, self).__init__()
self.hostname = network.format_hostname(config['mpd']['hostname'])
self.port = config['mpd']['port']
self.uri_map = uri_mapper.MpdUriMapper(core)
self.zeroconf_name = config['mpd']['zeroconf']
self.zeroconf_service = None
@ -29,6 +31,7 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
protocol_kwargs={
'config': config,
'core': core,
'uri_map': self.uri_map,
},
max_connections=config['mpd']['max_connections'],
timeout=config['mpd']['connection_timeout'])
@ -71,3 +74,6 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener):
def mute_changed(self, mute):
self.send_idle('output')
def stream_title_changed(self, title):
self.send_idle('playlist')

View File

@ -13,6 +13,7 @@ protocol.load_protocol_modules()
class MpdDispatcher(object):
"""
The MPD session feeds the MPD dispatcher with requests. The dispatcher
finds the correct handler, processes the request and sends the response
@ -21,7 +22,7 @@ class MpdDispatcher(object):
_noidle = re.compile(r'^noidle$')
def __init__(self, session=None, config=None, core=None):
def __init__(self, session=None, config=None, core=None, uri_map=None):
self.config = config
self.authenticated = False
self.command_list_receiving = False
@ -29,7 +30,7 @@ class MpdDispatcher(object):
self.command_list = []
self.command_list_index = None
self.context = MpdContext(
self, session=session, config=config, core=core)
self, session=session, config=config, core=core, uri_map=uri_map)
def handle_request(self, request, current_command_list_index=None):
"""Dispatch incoming requests to the correct handler."""
@ -163,6 +164,11 @@ class MpdDispatcher(object):
def _call_handler(self, request):
tokens = tokenize.split(request)
# TODO: check that blacklist items are valid commands?
blacklist = self.config['mpd'].get('command_blacklist', [])
if tokens and tokens[0] in blacklist:
logger.warning('Client sent us blacklisted command: %s', tokens[0])
raise exceptions.MpdDisabled(command=tokens[0])
try:
return protocol.commands.call(tokens, context=self.context)
except exceptions.MpdAckError as exc:
@ -204,6 +210,7 @@ class MpdDispatcher(object):
class MpdContext(object):
"""
This object is passed as the first argument to all MPD command handlers to
give the command handlers access to important parts of Mopidy.
@ -227,10 +234,10 @@ class MpdContext(object):
#: The subsytems that we want to be notified about in idle mode.
subscriptions = None
_invalid_browse_chars = re.compile(r'[\n\r]')
_invalid_playlist_chars = re.compile(r'[/]')
_uri_map = None
def __init__(self, dispatcher, session=None, config=None, core=None):
def __init__(self, dispatcher, session=None, config=None, core=None,
uri_map=None):
self.dispatcher = dispatcher
self.session = session
if config is not None:
@ -238,58 +245,19 @@ class MpdContext(object):
self.core = core
self.events = set()
self.subscriptions = set()
self._uri_from_name = {}
self._name_from_uri = {}
self.refresh_playlists_mapping()
self._uri_map = uri_map
def create_unique_name(self, name, uri):
stripped_name = self._invalid_browse_chars.sub(' ', name)
name = stripped_name
i = 2
while name in self._uri_from_name:
if self._uri_from_name[name] == uri:
return name
name = '%s [%d]' % (stripped_name, i)
i += 1
return name
def insert_name_uri_mapping(self, name, uri):
name = self.create_unique_name(name, uri)
self._uri_from_name[name] = uri
self._name_from_uri[uri] = name
return name
def refresh_playlists_mapping(self):
"""
Maintain map between playlists and unique playlist names to be used by
MPD
"""
if self.core is not None:
for playlist in self.core.playlists.playlists.get():
if not playlist.name:
continue
# TODO: add scheme to name perhaps 'foo (spotify)' etc.
name = self._invalid_playlist_chars.sub('|', playlist.name)
self.insert_name_uri_mapping(name, playlist.uri)
def lookup_playlist_from_name(self, name):
def lookup_playlist_uri_from_name(self, name):
"""
Helper function to retrieve a playlist from its unique MPD name.
"""
if not self._uri_from_name:
self.refresh_playlists_mapping()
if name not in self._uri_from_name:
return None
uri = self._uri_from_name[name]
return self.core.playlists.lookup(uri).get()
return self._uri_map.playlist_uri_from_name(name)
def lookup_playlist_name_from_uri(self, uri):
"""
Helper function to retrieve the unique MPD playlist name from its uri.
"""
if uri not in self._name_from_uri:
self.refresh_playlists_mapping()
return self._name_from_uri[uri]
return self._uri_map.playlist_name_from_uri(uri)
def browse(self, path, recursive=True, lookup=True):
"""
@ -301,10 +269,10 @@ class MpdContext(object):
given path.
If ``lookup`` is true and the ``path`` is to a track, the returned
``data`` is a future which will contain the
:class:`mopidy.models.Track` model. If ``lookup`` is false and the
``path`` is to a track, the returned ``data`` will be a
:class:`mopidy.models.Ref` for the track.
``data`` is a future which will contain the results from looking up
the URI with :meth:`mopidy.core.LibraryController.lookup` If ``lookup``
is false and the ``path`` is to a track, the returned ``data`` will be
a :class:`mopidy.models.Ref` for the track.
For all entries that are not tracks, the returned ``data`` will be
:class:`None`.
@ -313,8 +281,8 @@ class MpdContext(object):
path_parts = re.findall(r'[^/]+', path or '')
root_path = '/'.join([''] + path_parts)
if root_path not in self._uri_from_name:
uri = None
uri = self._uri_map.uri_from_name(root_path)
if uri is None:
for part in path_parts:
for ref in self.core.library.browse(uri).get():
if ref.type != ref.TRACK and ref.name == part:
@ -322,10 +290,7 @@ class MpdContext(object):
break
else:
raise exceptions.MpdNoExistError('Not found')
root_path = self.insert_name_uri_mapping(root_path, uri)
else:
uri = self._uri_from_name[root_path]
root_path = self._uri_map.insert(root_path, uri)
if recursive:
yield (root_path, None)
@ -335,11 +300,12 @@ class MpdContext(object):
base_path, future = path_and_futures.pop()
for ref in future.get():
path = '/'.join([base_path, ref.name.replace('/', '')])
path = self.insert_name_uri_mapping(path, ref.uri)
path = self._uri_map.insert(path, ref.uri)
if ref.type == ref.TRACK:
if lookup:
yield (path, self.core.library.lookup(ref.uri))
# TODO: can we lookup all the refs at once now?
yield (path, self.core.library.lookup(uris=[ref.uri]))
else:
yield (path, ref)
else:

View File

@ -4,6 +4,7 @@ from mopidy.exceptions import MopidyException
class MpdAckError(MopidyException):
"""See fields on this class for available MPD error codes"""
ACK_ERROR_NOT_LIST = 1
@ -59,6 +60,7 @@ class MpdUnknownError(MpdAckError):
class MpdUnknownCommand(MpdUnknownError):
def __init__(self, *args, **kwargs):
super(MpdUnknownCommand, self).__init__(*args, **kwargs)
assert self.command is not None, 'command must be given explicitly'
@ -67,6 +69,7 @@ class MpdUnknownCommand(MpdUnknownError):
class MpdNoCommand(MpdUnknownCommand):
def __init__(self, *args, **kwargs):
kwargs['command'] = ''
super(MpdNoCommand, self).__init__(*args, **kwargs)
@ -87,3 +90,13 @@ class MpdNotImplemented(MpdAckError):
def __init__(self, *args, **kwargs):
super(MpdNotImplemented, self).__init__(*args, **kwargs)
self.message = 'Not implemented'
class MpdDisabled(MpdAckError):
# NOTE: This is a custom error for Mopidy that does not exist in MPD.
error_code = 0
def __init__(self, *args, **kwargs):
super(MpdDisabled, self).__init__(*args, **kwargs)
assert self.command is not None, 'command must be given explicitly'
self.message = '"%s" has been disabled in the server' % self.command

View File

@ -6,3 +6,4 @@ password =
max_connections = 20
connection_timeout = 60
zeroconf = Mopidy MPD server on $hostname
command_blacklist = listall,listallinfo

View File

@ -83,6 +83,7 @@ def RANGE(value): # noqa: N802
class Commands(object):
"""Collection of MPD commands to expose to users.
Normally used through the global instance which command handlers have been

View File

@ -13,7 +13,9 @@ def disableoutput(context, outputid):
Turns an output off.
"""
if outputid == 0:
context.core.playback.set_mute(False)
success = context.core.mixer.set_mute(False).get()
if not success:
raise exceptions.MpdSystemError('problems disabling output')
else:
raise exceptions.MpdNoExistError('No such audio output')
@ -28,13 +30,14 @@ def enableoutput(context, outputid):
Turns an output on.
"""
if outputid == 0:
context.core.playback.set_mute(True)
success = context.core.mixer.set_mute(True).get()
if not success:
raise exceptions.MpdSystemError('problems enabling output')
else:
raise exceptions.MpdNoExistError('No such audio output')
# TODO: implement and test
# @protocol.commands.add('toggleoutput', outputid=protocol.UINT)
@protocol.commands.add('toggleoutput', outputid=protocol.UINT)
def toggleoutput(context, outputid):
"""
*musicpd.org, audio output section:*
@ -43,7 +46,13 @@ def toggleoutput(context, outputid):
Turns an output on or off, depending on the current state.
"""
pass
if outputid == 0:
mute_status = context.core.mixer.get_mute().get()
success = context.core.mixer.set_mute(not mute_status)
if not success:
raise exceptions.MpdSystemError('problems toggling output')
else:
raise exceptions.MpdNoExistError('No such audio output')
@protocol.commands.add('outputs')
@ -55,7 +64,7 @@ def outputs(context):
Shows information about all outputs.
"""
muted = 1 if context.core.playback.get_mute().get() else 0
muted = 1 if context.core.mixer.get_mute().get() else 0
return [
('outputid', 0),
('outputname', 'Mute'),

View File

@ -1,8 +1,7 @@
from __future__ import absolute_import, unicode_literals
import warnings
from mopidy.mpd import exceptions, protocol, translator
from mopidy.utils import deprecation
@protocol.commands.add('add')
@ -22,21 +21,21 @@ def add(context, uri):
if not uri.strip('/'):
return
if context.core.tracklist.add(uri=uri).get():
if context.core.tracklist.add(uris=[uri]).get():
return
try:
tracks = []
for path, lookup_future in context.browse(uri):
if lookup_future:
tracks.extend(lookup_future.get())
uris = []
for path, ref in context.browse(uri, lookup=False):
if ref:
uris.append(ref.uri)
except exceptions.MpdNoExistError as e:
e.message = 'directory or file not found'
raise
if not tracks:
if not uris:
raise exceptions.MpdNoExistError('directory or file not found')
context.core.tracklist.add(tracks=tracks)
context.core.tracklist.add(uris=uris).get()
@protocol.commands.add('addid', songpos=protocol.UINT)
@ -62,7 +61,8 @@ def addid(context, uri, songpos=None):
raise exceptions.MpdNoExistError('No such song')
if songpos is not None and songpos > context.core.tracklist.length.get():
raise exceptions.MpdArgError('Bad song index')
tl_tracks = context.core.tracklist.add(uri=uri, at_position=songpos).get()
tl_tracks = context.core.tracklist.add(
uris=[uri], at_position=songpos).get()
if not tl_tracks:
raise exceptions.MpdNoExistError('No such song')
return ('Id', tl_tracks[0].tlid)
@ -162,8 +162,7 @@ def playlist(context):
Do not use this, instead use ``playlistinfo``.
"""
warnings.warn(
'Do not use this, instead use playlistinfo', DeprecationWarning)
deprecation.warn('mpd.protocol.current_playlist.playlist')
return playlistinfo(context)
@ -275,9 +274,21 @@ def plchanges(context, version):
- Calls ``plchanges "-1"`` two times per second to get the entire playlist.
"""
# XXX Naive implementation that returns all tracks as changed
if int(version) < context.core.tracklist.version.get():
tracklist_version = context.core.tracklist.version.get()
if version < tracklist_version:
return translator.tracks_to_mpd_format(
context.core.tracklist.tl_tracks.get())
elif version == tracklist_version:
# A version match could indicate this is just a metadata update, so
# check for a stream ref and let the client know about the change.
stream_title = context.core.playback.get_stream_title().get()
if stream_title is None:
return None
tl_track = context.core.playback.current_tl_track.get()
position = context.core.tracklist.index(tl_track).get()
return translator.track_to_mpd_format(
tl_track, position=position, stream_title=stream_title)
@protocol.commands.add('plchangesposid', version=protocol.INT)
@ -337,8 +348,12 @@ def swap(context, songpos1, songpos2):
tracks.insert(songpos1, song2)
del tracks[songpos2]
tracks.insert(songpos2, song1)
# TODO: do we need a tracklist.replace()
context.core.tracklist.clear()
context.core.tracklist.add(tracks)
with deprecation.ignore('core.tracklist.add:tracks_arg'):
context.core.tracklist.add(tracks=tracks).get()
@protocol.commands.add('swapid', tlid1=protocol.UINT, tlid2=protocol.UINT)

View File

@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals
import functools
import itertools
import warnings
from mopidy.models import Track
from mopidy.mpd import exceptions, protocol, translator
@ -30,6 +31,15 @@ _LIST_MAPPING = {
'genre': 'genre',
'performer': 'performer'}
_LIST_NAME_MAPPING = {
'album': 'Album',
'albumartist': 'AlbumArtist',
'artist': 'Artist',
'composer': 'Composer',
'date': 'Date',
'genre': 'Genre',
'performer': 'Performer'}
def _query_from_mpd_search_parameters(parameters, mapping):
query = {}
@ -91,7 +101,7 @@ def count(context, *args):
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
except ValueError:
raise exceptions.MpdArgError('incorrect arguments')
results = context.core.library.find_exact(**query).get()
results = context.core.library.search(query=query, exact=True).get()
result_tracks = _get_tracks(results)
return [
('songs', len(result_tracks)),
@ -132,7 +142,7 @@ def find(context, *args):
except ValueError:
return
results = context.core.library.find_exact(**query).get()
results = context.core.library.search(query=query, exact=True).get()
result_tracks = []
if ('artist' not in query and
'albumartist' not in query and
@ -159,8 +169,14 @@ def findadd(context, *args):
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
except ValueError:
return
results = context.core.library.find_exact(**query).get()
context.core.tracklist.add(_get_tracks(results))
results = context.core.library.search(query=query, exact=True).get()
with warnings.catch_warnings():
# TODO: for now just use tracks as other wise we have to lookup the
# tracks we just got from the search.
warnings.filterwarnings('ignore', 'tracklist.add.*"tracks" argument.*')
context.core.tracklist.add(tracks=_get_tracks(results)).get()
@protocol.commands.add('list')
@ -246,109 +262,30 @@ def list_(context, *args):
- does not add quotes around the field argument.
- capitalizes the field argument.
"""
parameters = list(args)
if not parameters:
params = list(args)
if not params:
raise exceptions.MpdArgError('incorrect arguments')
field = parameters.pop(0).lower()
field = params.pop(0).lower()
if field not in _LIST_MAPPING:
raise exceptions.MpdArgError('incorrect arguments')
if len(parameters) == 1:
if len(params) == 1:
if field != 'album':
raise exceptions.MpdArgError('should be "Album" for 3 arguments')
return _list_album(context, {'artist': parameters})
query = {'artist': params}
else:
try:
query = _query_from_mpd_search_parameters(parameters, _LIST_MAPPING)
query = _query_from_mpd_search_parameters(params, _LIST_MAPPING)
except exceptions.MpdArgError as e:
e.message = 'not able to parse args'
raise
except ValueError:
return
if field == 'artist':
return _list_artist(context, query)
if field == 'albumartist':
return _list_albumartist(context, query)
elif field == 'album':
return _list_album(context, query)
elif field == 'composer':
return _list_composer(context, query)
elif field == 'performer':
return _list_performer(context, query)
elif field == 'date':
return _list_date(context, query)
elif field == 'genre':
return _list_genre(context, query)
def _list_artist(context, query):
artists = set()
results = context.core.library.find_exact(**query).get()
for track in _get_tracks(results):
for artist in track.artists:
if artist.name:
artists.add(('Artist', artist.name))
return artists
def _list_albumartist(context, query):
albumartists = set()
results = context.core.library.find_exact(**query).get()
for track in _get_tracks(results):
if track.album:
for artist in track.album.artists:
if artist.name:
albumartists.add(('AlbumArtist', artist.name))
return albumartists
def _list_album(context, query):
albums = set()
results = context.core.library.find_exact(**query).get()
for track in _get_tracks(results):
if track.album and track.album.name:
albums.add(('Album', track.album.name))
return albums
def _list_composer(context, query):
composers = set()
results = context.core.library.find_exact(**query).get()
for track in _get_tracks(results):
for composer in track.composers:
if composer.name:
composers.add(('Composer', composer.name))
return composers
def _list_performer(context, query):
performers = set()
results = context.core.library.find_exact(**query).get()
for track in _get_tracks(results):
for performer in track.performers:
if performer.name:
performers.add(('Performer', performer.name))
return performers
def _list_date(context, query):
dates = set()
results = context.core.library.find_exact(**query).get()
for track in _get_tracks(results):
if track.date:
dates.add(('Date', track.date))
return dates
def _list_genre(context, query):
genres = set()
results = context.core.library.find_exact(**query).get()
for track in _get_tracks(results):
if track.genre:
genres.add(('Genre', track.genre))
return genres
name = _LIST_NAME_MAPPING[field]
result = context.core.library.get_distinct(field, query)
return [(name, value) for value in result.get()]
@protocol.commands.add('listall')
@ -359,6 +296,13 @@ def listall(context, uri=None):
``listall [URI]``
Lists all songs and directories in ``URI``.
Do not use this command. Do not manage a client-side copy of MPD's
database. That is fragile and adds huge overhead. It will break with
large databases. Instead, query MPD whenever you need something.
.. warning:: This command is disabled by default in Mopidy installs.
"""
result = []
for path, track_ref in context.browse(uri, lookup=False):
@ -381,13 +325,21 @@ def listallinfo(context, uri=None):
Same as ``listall``, except it also returns metadata info in the
same format as ``lsinfo``.
Do not use this command. Do not manage a client-side copy of MPD's
database. That is fragile and adds huge overhead. It will break with
large databases. Instead, query MPD whenever you need something.
.. warning:: This command is disabled by default in Mopidy installs.
"""
result = []
for path, lookup_future in context.browse(uri):
if not lookup_future:
result.append(('directory', path))
else:
for track in lookup_future.get():
for tracks in lookup_future.get().values():
for track in tracks:
result.extend(translator.track_to_mpd_format(track))
return result
@ -414,7 +366,7 @@ def lsinfo(context, uri=None):
if not lookup_future:
result.append(('directory', path.lstrip('/')))
else:
tracks = lookup_future.get()
for tracks in lookup_future.get().values():
if tracks:
result.extend(translator.track_to_mpd_format(tracks[0]))
@ -468,7 +420,7 @@ def search(context, *args):
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
except ValueError:
return
results = context.core.library.search(**query).get()
results = context.core.library.search(query).get()
artists = [_artist_as_track(a) for a in _get_artists(results)]
albums = [_album_as_track(a) for a in _get_albums(results)]
tracks = _get_tracks(results)
@ -492,8 +444,14 @@ def searchadd(context, *args):
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
except ValueError:
return
results = context.core.library.search(**query).get()
context.core.tracklist.add(_get_tracks(results))
results = context.core.library.search(query).get()
with warnings.catch_warnings():
# TODO: for now just use tracks as other wise we have to lookup the
# tracks we just got from the search.
warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*')
context.core.tracklist.add(_get_tracks(results)).get()
@protocol.commands.add('searchaddpl')
@ -519,9 +477,10 @@ def searchaddpl(context, *args):
query = _query_from_mpd_search_parameters(parameters, _SEARCH_MAPPING)
except ValueError:
return
results = context.core.library.search(**query).get()
results = context.core.library.search(query).get()
playlist = context.lookup_playlist_from_name(playlist_name)
uri = context.lookup_playlist_uri_from_name(playlist_name)
playlist = uri is not None and context.core.playlists.lookup(uri).get()
if not playlist:
playlist = context.core.playlists.create(playlist_name).get()
tracks = list(playlist.tracks) + _get_tracks(results)

View File

@ -1,9 +1,8 @@
from __future__ import absolute_import, unicode_literals
import warnings
from mopidy.core import PlaybackState
from mopidy.mpd import exceptions, protocol
from mopidy.utils import deprecation
@protocol.commands.add('consume', state=protocol.BOOL)
@ -32,8 +31,7 @@ def crossfade(context, seconds):
raise exceptions.MpdNotImplemented # TODO
# TODO: add at least reflection tests before adding NotImplemented version
# @protocol.commands.add('mixrampdb')
@protocol.commands.add('mixrampdb')
def mixrampdb(context, decibels):
"""
*musicpd.org, playback section:*
@ -46,11 +44,10 @@ def mixrampdb(context, decibels):
volume so use negative values, I prefer -17dB. In the absence of mixramp
tags crossfading will be used. See http://sourceforge.net/projects/mixramp
"""
pass
raise exceptions.MpdNotImplemented # TODO
# TODO: add at least reflection tests before adding NotImplemented version
# @protocol.commands.add('mixrampdelay', seconds=protocol.UINT)
@protocol.commands.add('mixrampdelay', seconds=protocol.UINT)
def mixrampdelay(context, seconds):
"""
*musicpd.org, playback section:*
@ -61,7 +58,7 @@ def mixrampdelay(context, seconds):
value of "nan" disables MixRamp overlapping and falls back to
crossfading.
"""
pass
raise exceptions.MpdNotImplemented # TODO
@protocol.commands.add('next')
@ -136,9 +133,7 @@ def pause(context, state=None):
- Calls ``pause`` without any arguments to toogle pause.
"""
if state is None:
warnings.warn(
'The use of pause command w/o the PAUSE argument is deprecated.',
DeprecationWarning)
deprecation.warn('mpd.protocol.playback.pause:state_arg')
if (context.core.playback.state.get() == PlaybackState.PLAYING):
context.core.playback.pause()
@ -397,7 +392,10 @@ def setvol(context, volume):
- issues ``setvol 50`` without quotes around the argument.
"""
# NOTE: we use INT as clients can pass in +N etc.
context.core.playback.volume = min(max(0, volume), 100)
value = min(max(0, volume), 100)
success = context.core.mixer.set_volume(value).get()
if not success:
raise exceptions.MpdSystemError('problems setting volume')
@protocol.commands.add('single', state=protocol.BOOL)

View File

@ -35,9 +35,11 @@ def currentsong(context):
identified in status).
"""
tl_track = context.core.playback.current_tl_track.get()
stream_title = context.core.playback.get_stream_title().get()
if tl_track is not None:
position = context.core.tracklist.index(tl_track).get()
return translator.track_to_mpd_format(tl_track, position=position)
return translator.track_to_mpd_format(
tl_track, position=position, stream_title=stream_title)
@protocol.commands.add('idle', list_command=False)
@ -173,7 +175,7 @@ def status(context):
futures = {
'tracklist.length': context.core.tracklist.length,
'tracklist.version': context.core.tracklist.version,
'playback.volume': context.core.playback.volume,
'mixer.volume': context.core.mixer.get_volume(),
'tracklist.consume': context.core.tracklist.consume,
'tracklist.random': context.core.tracklist.random,
'tracklist.repeat': context.core.tracklist.repeat,
@ -287,7 +289,7 @@ def _status_time_total(futures):
def _status_volume(futures):
volume = futures['playback.volume'].get()
volume = futures['mixer.volume'].get()
if volume is not None:
return volume
else:

View File

@ -1,6 +1,7 @@
from __future__ import absolute_import, division, unicode_literals
import datetime
import warnings
from mopidy.mpd import exceptions, protocol, translator
@ -20,7 +21,8 @@ def listplaylist(context, name):
file: relative/path/to/file2.ogg
file: relative/path/to/file3.mp3
"""
playlist = context.lookup_playlist_from_name(name)
uri = context.lookup_playlist_uri_from_name(name)
playlist = uri is not None and context.core.playlists.lookup(uri).get()
if not playlist:
raise exceptions.MpdNoExistError('No such playlist')
return ['file: %s' % t.uri for t in playlist.tracks]
@ -40,7 +42,8 @@ def listplaylistinfo(context, name):
Standard track listing, with fields: file, Time, Title, Date,
Album, Artist, Track
"""
playlist = context.lookup_playlist_from_name(name)
uri = context.lookup_playlist_uri_from_name(name)
playlist = uri is not None and context.core.playlists.lookup(uri).get()
if not playlist:
raise exceptions.MpdNoExistError('No such playlist')
return translator.playlist_to_mpd_format(playlist)
@ -73,7 +76,7 @@ def listplaylists(context):
ignore playlists without names, which isn't very useful anyway.
"""
result = []
for playlist in context.core.playlists.playlists.get():
for playlist in context.core.playlists.get_playlists().get():
if not playlist.name:
continue
name = context.lookup_playlist_name_from_uri(playlist.uri)
@ -121,10 +124,14 @@ def load(context, name, playlist_slice=slice(0, None)):
- MPD 0.17.1 does not fail if the specified range is outside the playlist,
in either or both ends.
"""
playlist = context.lookup_playlist_from_name(name)
uri = context.lookup_playlist_uri_from_name(name)
playlist = uri is not None and context.core.playlists.lookup(uri).get()
if not playlist:
raise exceptions.MpdNoExistError('No such playlist')
context.core.tracklist.add(playlist.tracks[playlist_slice])
with warnings.catch_warnings():
warnings.filterwarnings('ignore', 'tracklist.add.*"tracks".*')
context.core.tracklist.add(playlist.tracks[playlist_slice]).get()
@protocol.commands.add('playlistadd')

View File

@ -9,6 +9,7 @@ logger = logging.getLogger(__name__)
class MpdSession(network.LineProtocol):
"""
The MPD client session. Keeps track of a single client session. Any
requests from the client is passed on to the MPD request dispatcher.
@ -18,10 +19,10 @@ class MpdSession(network.LineProtocol):
encoding = protocol.ENCODING
delimiter = r'\r?\n'
def __init__(self, connection, config=None, core=None):
def __init__(self, connection, config=None, core=None, uri_map=None):
super(MpdSession, self).__init__(connection)
self.dispatcher = dispatcher.MpdDispatcher(
session=self, config=config, core=core)
session=self, config=config, core=core, uri_map=uri_map)
def on_start(self):
logger.info('New MPD connection from [%s]:%s', self.host, self.port)

View File

@ -15,7 +15,7 @@ def normalize_path(path, relative=False):
return '/'.join(parts)
def track_to_mpd_format(track, position=None):
def track_to_mpd_format(track, position=None, stream_title=None):
"""
Format track for output to MPD client.
@ -23,24 +23,28 @@ def track_to_mpd_format(track, position=None):
:type track: :class:`mopidy.models.Track` or :class:`mopidy.models.TlTrack`
:param position: track's position in playlist
:type position: integer
:param key: if we should set key
:type key: boolean
:param mtime: if we should set mtime
:type mtime: boolean
:param stream_title: the current streams title
:type position: string
:rtype: list of two-tuples
"""
if isinstance(track, TlTrack):
(tlid, track) = track
else:
(tlid, track) = (None, track)
result = [
('file', track.uri or ''),
# TODO: only show length if not none, see:
# https://github.com/mopidy/mopidy/issues/923#issuecomment-79584110
('Time', track.length and (track.length // 1000) or 0),
('Artist', artists_to_mpd_format(track.artists)),
('Title', track.name or ''),
('Album', track.album and track.album.name or ''),
]
if stream_title:
result.append(('Name', stream_title))
if track.date:
result.append(('Date', track.date))

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