Merge branch 'master' into gstreamer

Conflicts:
	mopidy/backends/__init__.py
	mopidy/mpd/handler.py
This commit is contained in:
Thomas Adamcik 2010-04-05 19:18:20 +02:00
commit 2d65666d58
68 changed files with 5829 additions and 2186 deletions

11
.gitignore vendored
View File

@ -2,9 +2,14 @@
*.swp
.coverage
.idea
docs/_build
local_settings.py
.noseids
MANIFEST
build/
cover/
coverage.xml
dist/
docs/_build/
nosetests.xml
pip-log.txt
spotify_appkey.key
src/
tmp/

View File

@ -3,5 +3,12 @@ Authors
Contributors to Mopidy in the order of appearance:
* Stein Magnus Jodal <stein.magnus@jodal.no>
* Johannes Knutsen <johannes@knutseninfo.no>
- Stein Magnus Jodal <stein.magnus@jodal.no>
- Johannes Knutsen <johannes@knutseninfo.no>
- Thomas Adamcik <adamcik@samfundet.no>
- Kristian Klette <klette@klette.us>
Also, we would like to thank:
- Jørgen P. Tjernø for his work on the Python wrapper for Despotify.
- Doug Winter for his work on the Python wrapper for libspotify.

4
MANIFEST.in Normal file
View File

@ -0,0 +1,4 @@
include COPYING
include *.rst
include requirements*.txt
recursive-include docs *.rst

View File

@ -7,10 +7,11 @@ Mopidy is an `MPD <http://mpd.wikia.com/>`_ server with a
can search for music in Spotify's vast archive, manage Spotify play lists and
play music from Spotify.
Mopidy is currently under development. Unless you want to contribute to the
development, you should probably wait for our first release before trying out
Mopidy.
To install Mopidy, check out
`the installation docs <http://www.mopidy.com/docs/installation/>`_.
* `Source code <http://github.com/jodal/mopidy>`_
* `Documentation <http://www.mopidy.com/>`_
* `Source code <http://github.com/jodal/mopidy>`_
* `Issue tracker <http://github.com/jodal/mopidy/issues>`_
* IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
* `Presentation of Mopidy <http://www.slideshare.net/jodal/mopidy-3380516>`_

5
bin/mopidy Normal file
View File

@ -0,0 +1,5 @@
#! /usr/bin/env python
if __name__ == '__main__':
from mopidy.__main__ import main
main()

BIN
docs/_static/thread_communication.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

37
docs/_static/thread_communication.txt vendored Normal file
View File

@ -0,0 +1,37 @@
Script for use with www.websequencediagrams.com
===============================================
Main -> Core: create
activate Core
note over Core: create NadMixer
Core -> NadTalker: create
activate NadTalker
note over NadTalker: calibrate device
note over Core: create DespotifyBackend
Core -> despotify: connect to Spotify
activate despotify
note over Core: create MpdFrontend
Main -> Server: create
activate Server
note over Server: open port
Client -> Server: connect
note over Server: open session
Client -> Server: play 1
Server -> Core: play 1
Core -> despotify: play first track
Client -> Server: setvol 50
Server -> Core: setvol 50
Core -> NadTalker: volume = 50
Client -> Server: status
Server -> Core: status
Core -> NadTalker: volume?
NadTalker -> Core: volume = 50
Core -> Server: status response
Server -> Client: status response
despotify -> Core: end of track callback
Core -> despotify: play second track
Client -> Server: stop
Server -> Core: stop
Core -> despotify: stop
Client -> Server: disconnect
note over Server: close session

15
docs/_templates/layout.html vendored Normal file
View File

@ -0,0 +1,15 @@
{% extends "!layout.html" %}
{% block footer %}
{{ super() }}
<script type="text/javascript">
var gaJsHost = (("https:" == document.location.protocol) ? "https://ssl." : "http://www.");
document.write(unescape("%3Cscript src='" + gaJsHost + "google-analytics.com/ga.js' type='text/javascript'%3E%3C/script%3E"));
</script>
<script type="text/javascript">
try {
var pageTracker = _gat._getTracker("UA-15510432-1");
pageTracker._trackPageview();
} catch(err) {}
</script>
{% endblock %}

View File

@ -1,294 +1,69 @@
*************************************
:mod:`mopidy.backends` -- Backend API
*************************************
**********************
:mod:`mopidy.backends`
**********************
.. warning::
This is our *planned* backend API, and not the current API.
The backend and its controllers
===============================
.. module:: mopidy.backends
:synopsis: Interface between Mopidy and its various backends.
.. graph:: backend_relations
.. class:: BaseBackend()
backend -- current_playlist
backend -- library
backend -- playback
backend -- stored_playlists
.. attribute:: current_playlist
The current playlist controller. An instance of
:class:`BaseCurrentPlaylistController`.
Backend API
===========
.. attribute:: library
.. note::
The library controller. An instance of :class:`BaseLibraryController`.
Currently this only documents the API that is available for use by
frontends like :class:`mopidy.mpd.handler`, and not what is required to
implement your own backend. :class:`mopidy.backends.BaseBackend` and its
controllers implements many of these methods in a matter that should be
independent of most concrete backend implementations, so you should
generally just implement or override a few of these methods yourself to
create a new backend with a complete feature set.
.. attribute:: playback
.. automodule:: mopidy.backends
:synopsis: Backend interface.
:members:
:undoc-members:
The playback controller. An instance of :class:`BasePlaybackController`.
.. attribute:: stored_playlists
Spotify backends
================
The stored playlists controller. An instance of
:class:`BaseStoredPlaylistsController`.
:mod:`mopidy.backends.despotify` -- Despotify backend
-----------------------------------------------------
.. attribute:: uri_handlers
.. automodule:: mopidy.backends.despotify
:synopsis: Spotify backend using the despotify library.
:members:
List of URI prefixes this backend can handle.
:mod:`mopidy.backends.libspotify` -- Libspotify backend
-------------------------------------------------------
.. class:: BaseCurrentPlaylistController(backend)
.. automodule:: mopidy.backends.libspotify
:synopsis: Spotify backend using the libspotify library.
:members:
:param backend: backend the controller is a part of
:type backend: :class:`BaseBackend`
.. method:: add(track, at_position=None)
Other backends
==============
Add the track to the end of, or at the given position in the current
playlist.
:mod:`mopidy.backends.dummy` -- Dummy backend
---------------------------------------------
:param track: track to add
:type track: :class:`mopidy.models.Track`
:param at_position: position in current playlist to add track
:type at_position: int or :class:`None`
.. automodule:: mopidy.backends.dummy
:synopsis: Dummy backend used for testing.
:members:
.. method:: clear()
Clear the current playlist.
GStreamer backend
-----------------
.. method:: load(playlist)
Replace the current playlist with the given playlist.
:param playlist: playlist to load
:type playlist: :class:`mopidy.models.Playlist`
.. method:: move(start, end, to_position)
Move the tracks in the slice ``[start:end]`` to ``to_position``.
:param start: position of first track to move
:type start: int
:param end: position after last track to move
:type end: int
:param to_position: new position for the tracks
:type to_position: int
.. attribute:: playlist
The currently loaded :class:`mopidy.models.Playlist`.
.. method:: remove(track)
Remove the track from the current playlist.
:param track: track to remove
:type track: :class:`mopidy.models.Track`
.. method:: shuffle(start=None, end=None)
Shuffles the entire playlist. If ``start`` and ``end`` is given only
shuffles the slice ``[start:end]``.
:param start: position of first track to shuffle
:type start: int or :class:`None`
:param end: position after last track to shuffle
:type end: int or :class:`None`
.. attribute:: version
The current playlist version. Integer which is increased every time the
current playlist is changed.
.. class:: BasePlaybackController(backend)
:param backend: backend the controller is a part of
:type backend: :class:`BaseBackend`
.. attribute:: consume
:class:`True`
Tracks are removed from the playlist when they have been played.
:class:`False`
Tracks are not removed from the playlist.
.. attribute:: current_track
The currently playing or selected :class:`mopidy.models.Track`.
.. method:: next()
Play the next track.
.. method:: pause()
Pause playblack.
.. attribute:: PAUSED
Constant representing the paused state.
.. method:: play(track=None)
Play the given track or the currently active track.
:param track: track to play
:type track: :class:`mopidy.models.Track` or :class:`None`
.. attribute:: PLAYING
Constant representing the playing state.
.. attribute:: playlist_position
The position in the current playlist.
.. method:: previous()
Play the previous track.
.. attribute:: random
:class:`True`
Tracks are selected at random from the playlist.
:class:`False`
Tracks are played in the order of the playlist.
.. attribute:: repeat
:class:`True`
The current track is played repeatedly.
:class:`False`
The current track is played once.
.. method:: resume()
If paused, resume playing the current track.
.. method:: seek(time_position)
Seeks to time position given in milliseconds.
:param time_position: time position in milliseconds
:type time_position: int
.. attribute:: state
The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or
:attr:`STOPPED`.
.. method:: stop()
Stop playing.
.. attribute:: STOPPED
Constant representing the stopped state.
.. attribute:: time_position
Time position in milliseconds.
.. attribute:: volume
The audio volume as an int in the range [0, 100]. :class:`None` if
unknown.
.. class:: BaseLibraryController(backend)
:param backend: backend the controller is a part of
:type backend: :class:`BaseBackend`
.. method:: find_exact(type, query)
Find tracks in the library where ``type`` matches ``query`` exactly.
:param type: 'title', 'artist', or 'album'
:type type: string
:param query: the search query
:type query: string
:rtype: list of :class:`mopidy.models.Track`
.. method:: lookup(uri)
Lookup track with given URI.
:param uri: track URI
:type uri: string
:rtype: :class:`mopidy.models.Track`
.. method:: refresh(uri=None)
Refresh library. Limit to URI and below if an URI is given.
:param uri: directory or track URI
:type uri: string
.. method:: search(type, query)
Search the library for tracks where ``type`` contains ``query``.
:param type: 'title', 'artist', 'album', or 'uri'
:type type: string
:param query: the search query
:type query: string
:rtype: list of :class:`mopidy.models.Track`
.. class:: BaseStoredPlaylistsController(backend)
:param backend: backend the controller is a part of
:type backend: :class:`BaseBackend`
.. method:: add(uri)
Add existing playlist with the given URI.
:param uri: URI of existing playlist
:type uri: string
.. method:: create(name)
Create a new playlist.
:param name: name of the new playlist
:type name: string
:rtype: :class:`mopidy.models.Playlist`
.. attribute:: playlists
List of :class:`mopidy.models.Playlist`.
.. method:: delete(playlist)
Delete playlist.
:param playlist: the playlist to delete
:type playlist: :class:`mopidy.models.Playlist`
.. method:: lookup(uri)
Lookup playlist with given URI.
:param uri: playlist URI
:type uri: string
:rtype: :class:`mopidy.models.Playlist`
.. method:: refresh()
Refresh stored playlists.
.. method:: rename(playlist, new_name)
Rename playlist.
:param playlist: the playlist
:type playlist: :class:`mopidy.models.Playlist`
:param new_name: the new name
:type new_name: string
.. method:: search(query)
Search for playlists whose name contains ``query``.
:param query: query to search for
:type query: string
:rtype: list of :class:`mopidy.models.Playlist`
``GstreamerBackend`` is pending merge from `adamcik/mopidy/gstreamer
<http://github.com/adamcik/mopidy/tree/gstreamer>`_.

8
docs/api/index.rst Normal file
View File

@ -0,0 +1,8 @@
*****************
API documentation
*****************
.. toctree::
:glob:
**

96
docs/api/mixers.rst Normal file
View File

@ -0,0 +1,96 @@
********************
:mod:`mopidy.mixers`
********************
Mixers are responsible for controlling volume. Clients of the mixers will
simply instantiate a mixer and read/write to the ``volume`` attribute::
>>> from mopidy.mixers.alsa import AlsaMixer
>>> mixer = AlsaMixer()
>>> mixer.volume
100
>>> mixer.volume = 80
>>> mixer.volume
80
Mixer API
=========
All mixers should subclass :class:`mopidy.mixers.BaseMixer` and override
methods as described below.
.. automodule:: mopidy.mixers
:synopsis: Sound mixer interface.
:members:
:undoc-members:
Internal mixers
===============
Most users will use one of these internal mixers which controls the volume on
the computer running Mopidy. If you do not specify which mixer you want to use
in the settings, Mopidy will choose one for you based upon what OS you run. See
:attr:`mopidy.settings.MIXER` for the defaults.
:mod:`mopidy.mixers.alsa` -- ALSA mixer
---------------------------------------
.. automodule:: mopidy.mixers.alsa
:synopsis: ALSA mixer
:members:
.. inheritance-diagram:: mopidy.mixers.alsa.AlsaMixer
:mod:`mopidy.mixers.dummy` -- Dummy mixer
-----------------------------------------
.. automodule:: mopidy.mixers.dummy
:synopsis: Dummy mixer
:members:
.. inheritance-diagram:: mopidy.mixers.dummy
:mod:`mopidy.mixers.osa` -- Osa mixer
-------------------------------------
.. automodule:: mopidy.mixers.osa
:synopsis: Osa mixer
:members:
.. inheritance-diagram:: mopidy.mixers.osa
External device mixers
======================
Mopidy supports controlling volume on external devices instead of on the
computer running Mopidy through the use of custom mixer implementations. To
enable one of the following mixers, you must the set
:attr:`mopidy.settings.MIXER` setting to point to one of the classes
found below, and possibly add some extra settings required by the mixer you
choose.
:mod:`mopidy.mixers.denon` -- Denon amplifier mixer
---------------------------------------------------
.. automodule:: mopidy.mixers.denon
:synopsis: Denon amplifier mixer
:members:
.. inheritance-diagram:: mopidy.mixers.denon
:mod:`mopidy.mixers.nad` -- NAD amplifier mixer
-----------------------------------------------
.. automodule:: mopidy.mixers.nad
:synopsis: NAD amplifier mixer
:members:
.. inheritance-diagram:: mopidy.mixers.nad

View File

@ -1,6 +1,26 @@
*********************************************
:mod:`mopidy.models` -- Immutable data models
*********************************************
********************
:mod:`mopidy.models`
********************
These immutable data models are used for all data transfer within the Mopidy
backends and between the backends and the MPD frontend. All fields are optional
and immutable. In other words, they can only be set through the class
constructor during instance creation.
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" ]
Data model API
==============
.. automodule:: mopidy.models
:synopsis: Immutable data models.

22
docs/api/mpd.rst Normal file
View File

@ -0,0 +1,22 @@
*****************
:mod:`mopidy.mpd`
*****************
MPD protocol implementation
===========================
.. automodule:: mopidy.mpd.frontend
:synopsis: Our MPD protocol implementation.
:members:
:undoc-members:
MPD server implementation
=========================
.. automodule:: mopidy.mpd.server
:synopsis: Our MPD server implementation.
:members:
:undoc-members:
.. inheritance-diagram:: mopidy.mpd.server

27
docs/api/settings.rst Normal file
View File

@ -0,0 +1,27 @@
**********************
:mod:`mopidy.settings`
**********************
Changing settings
=================
For any Mopidy installation you will need to change at least a couple of
settings. To do this, create a new file in the ``~/.mopidy/`` directory
named ``settings.py`` and add settings you need to change from their defaults
there.
A complete ``~/.mopidy/settings.py`` may look like this::
SERVER_HOSTNAME = u'0.0.0.0'
SPOTIFY_USERNAME = u'alice'
SPOTIFY_USERNAME = u'mysecret'
Available settings
==================
.. automodule:: mopidy.settings
:synopsis: Available settings and their default values.
:members:
:undoc-members:

1
docs/authors.rst Normal file
View File

@ -0,0 +1 @@
.. include:: ../AUTHORS.rst

View File

@ -0,0 +1,10 @@
def setup(app):
app.connect('autodoc-skip-member', autodoc_private_members_with_doc)
def autodoc_private_members_with_doc(app, what, name, obj, skip, options):
if not skip:
return skip
if (name.startswith('_') and obj.__doc__ is not None
and not (name.startswith('__') and name.endswith('__'))):
return False
return skip

View File

@ -5,11 +5,19 @@ Changes
This change log is used to track all major changes to Mopidy.
0.1 (unreleased)
================
0.1.0a0 (2010-03-27)
====================
Initial version.
"*Release early. Release often. Listen to your customers.*" wrote Eric S.
Raymond in *The Cathedral and the Bazaar*.
Features:
Three months of development should be more than enough. We have more to do, but
Mopidy is working and usable. 0.1.0a0 is an alpha release, which basicly means
we will still change APIs, add features, etc. before the final 0.1.0 release.
But the software is usable as is, so we release it. Please give it a try and
give us feedback, either at our IRC channel or through the `issue tracker
<http://github.com/jodal/mopidy/issues>`_. Thanks!
* *TODO:* Fill out
**Changes**
- Initial version. No changelog available.

View File

@ -16,13 +16,17 @@ import sys, os
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
sys.path.append(os.path.abspath(os.path.dirname(__file__)))
sys.path.append(os.path.abspath(os.path.dirname(__file__) + '/../'))
import mopidy
# -- General configuration -----------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = ['sphinx.ext.autodoc']
extensions = ['sphinx.ext.autodoc', 'autodoc_private_members',
'sphinx.ext.graphviz', 'sphinx.ext.inheritance_diagram']
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@ -44,10 +48,11 @@ copyright = u'2010, Stein Magnus Jodal'
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '0.1'
# The full version, including alpha/beta/rc tags.
release = '0.1'
release = mopidy.get_version()
# The short X.Y version.
version = '.'.join(release.split('.')[:2])
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@ -84,7 +89,7 @@ exclude_trees = ['_build']
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
modindex_common_prefix = ['mopidy.']
# -- Options for HTML output ---------------------------------------------------
@ -124,7 +129,7 @@ html_static_path = ['_static']
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
@ -192,3 +197,4 @@ latex_documents = [
# If false, no module index is generated.
#latex_use_modindex = True

View File

@ -1,94 +0,0 @@
***********
Development
***********
Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at
``irc.freenode.net`` and through `GitHub <http://github.com/>`_.
API documentation
=================
.. toctree::
:glob:
api/*
Scope
=====
To limit scope, we will start by implementing an MPD server which only
supports Spotify, and not playback of files from disk. We will make Mopidy
modular, so we can extend it with other backends in the future, like file
playback and other online music services such as Last.fm.
Running tests
=============
To run tests, you need a couple of dependiencies. Some can be installed through Debian/Ubuntu package management::
sudo aptitude install python-coverage
The rest can be installed using pip::
sudo aptitude install python-pip python-setuptools bzr
sudo pip install -r test-requirements.txt
Then, to run all tests::
python tests
Music Player Daemon (MPD)
=========================
The `MPD protocol documentation <http://www.musicpd.org/doc/protocol/>`_ is a
useful resource. It is rather incomplete with regards to data formats, both for
requests and responses. Thus we have to talk a great deal with the the original
`MPD server <http://mpd.wikia.com/>`_ using telnet to get the details we need
to implement our own MPD server which is compatible with the numerous existing
`MPD clients <http://mpd.wikia.com/wiki/Clients>`_.
spytify
=======
`spytify <http://despotify.svn.sourceforge.net/viewvc/despotify/src/bindings/python/>`_
is the Python bindings for the open source `despotify <http://despotify.se/>`_
library. It got no documentation to speak of, but a couple of examples are
available.
Issues
------
A list of the issues we currently experience with spytify, both bugs and
features we wished was there.
* r483: Sometimes segfaults when traversing stored playlists, their tracks,
artists, and albums. As it is not predictable, it may be a concurrency issue.
* r503: Segfaults when looking up playlists, both your own lists and other
peoples shared lists. To reproduce::
>>> import spytify
>>> s = spytify.Spytify('alice', 'secret')
>>> s.lookup('spotify:user:klette:playlist:5rOGYPwwKqbAcVX8bW4k5V')
Segmentation fault
pyspotify
=========
`pyspotify <http://github.com/winjer/pyspotify/>`_ is the Python bindings for
the official Spotify library, libspotify. It got no documentation to speak of,
but multiple examples are available.
Issues
------
A list of the issues we currently experience with pyspotify, both bugs and
features we wished was there.
* None at the moment.

View File

@ -0,0 +1,162 @@
*****************
How to contribute
*****************
Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at
``irc.freenode.net`` and through `GitHub <http://github.com/>`_.
Code style
==========
- Follow :pep:`8` unless otherwise noted. `pep8.py
<http://pypi.python.org/pypi/pep8/>`_ can be used to check your code against
the guidelines, however remember that matching the style of the surrounding
code is also important.
- Use four spaces for indentation, *never* tabs.
- Use CamelCase with initial caps for class names::
ClassNameWithCamelCase
- Use underscore to split variable, function and method names for
readability. Don't use CamelCase.
::
lower_case_with_underscores
- Use the fact that empty strings, lists and tuples are :class:`False` and
don't compare boolean values using ``==`` and ``!=``.
- Follow whitespace rules as described in :pep:`8`. Good examples::
spam(ham[1], {eggs: 2})
spam(1)
dict['key'] = list[index]
- Limit lines to 80 characters and avoid trailing whitespace. However note that
wrapped lines should be *one* indentation level in from level above, except
for ``if``, ``for``, ``with``, and ``while`` lines which should have two
levels of indentation::
if (foo and bar ...
baz and foobar):
a = 1
from foobar import (foo, bar, ...
baz)
- For consistency, prefer ``'`` over ``"`` for strings, unless the string
contains ``'``.
- Take a look at :pep:`20` for a nice peek into a general mindset useful for
Python coding.
Commit guidelines
=================
- Keep commits small and on topic.
- If a commit looks too big you should be working in a feature branch not a
single commit.
- Merge feature branches with ``--no-ff`` to keep track of the merge.
Running tests
=============
To run tests, you need a couple of dependencies. They can be installed through
Debian/Ubuntu package management::
sudo aptitude install python-coverage python-nose
Or, they can be installed using ``pip``::
sudo pip install -r requirements-tests.txt
Then, to run all tests, go to the project directory and run::
nosetests
For example::
$ nosetests
......................................................................
......................................................................
......................................................................
.......
----------------------------------------------------------------------
Ran 217 tests in 0.267s
OK
To run tests with test coverage statistics::
nosetests --with-coverage
For more documentation on testing, check out the `nose documentation
<http://somethingaboutorange.com/mrl/projects/nose/>`_.
Continuous integration server
=============================
We run a continuous integration server called Hudson at
http://hudson.mopidy.com/ that runs all test on multiple platforms (Ubuntu, OS
X, etc.) for every commit we push to GitHub. If the build is broken or fixed,
Hudson will issue notifications to our IRC channel.
In addition to running tests, Hudson also does coverage statistics and uses
pylint to check for errors and possible improvements in our code. So, if you're
out of work, the code coverage and pylint data in Hudson should give you a
place to start.
Writing documentation
=====================
To write documentation, we use `Sphinx <http://sphinx.pocoo.org/>`_. See their
site for lots of documentation on how to use Sphinx. To generate HTML or LaTeX
from the documentation files, you need some additional dependencies.
You can install them through Debian/Ubuntu package management::
sudo aptitude install python-sphinx python-pygraphviz graphviz
Then, to generate docs::
cd docs/
make # For help on available targets
make html # To generate HTML docs
.. note::
The documentation at http://www.mopidy.com/docs/ is automatically updated
within 10 minutes after a documentation update is pushed to
``jodal/mopidy/master`` at GitHub.
Creating releases
=================
1. Update changelog and commit it.
2. Tag release::
git tag -a -m "Release v0.1.0a0" v0.1.0a0
3. Push to GitHub::
git push
git push --tags
4. Build package and upload to PyPI::
rm MANIFEST # Will be regenerated by setup.py
python setup.py sdist upload
5. Spread the word.

View File

@ -0,0 +1,10 @@
***********
Development
***********
.. toctree::
:maxdepth: 3
roadmap
contributing
internals

View File

@ -0,0 +1,59 @@
*********
Internals
*********
Some of the following notes and details will hopefully be useful when you start
developing on Mopidy, while some may only be useful when you get deeper into
specific parts of Mopidy.
In addition to what you'll find here, don't forget the :doc:`/api/index`.
Class instantiation and usage
=============================
The following diagram shows how Mopidy with the despotify backend and ALSA
mixer is wired together. The gray nodes are part of external dependencies, and
not Mopidy. The red nodes lives in the ``main`` process (running an
:mod:`asyncore` loop), while the blue nodes lives in a secondary process named
``core`` (running a service loop in :class:`mopidy.core.CoreProcess`).
.. digraph:: class_instantiation_and_usage
"spytify" [ color="gray" ]
"despotify" [ color="gray" ]
"alsaaudio" [ color="gray" ]
"__main__" [ color="red" ]
"CoreProcess" [ color="blue" ]
"DespotifyBackend" [ color="blue" ]
"AlsaMixer" [ color="blue" ]
"MpdFrontend" [ color="blue" ]
"MpdServer" [ color="red" ]
"MpdSession" [ color="red" ]
"__main__" -> "CoreProcess" [ label="create" ]
"__main__" -> "MpdServer" [ label="create" ]
"CoreProcess" -> "DespotifyBackend" [ label="create" ]
"CoreProcess" -> "MpdFrontend" [ label="create" ]
"MpdServer" -> "MpdSession" [ label="create one per client" ]
"MpdSession" -> "MpdFrontend" [ label="pass MPD requests to" ]
"MpdFrontend" -> "DespotifyBackend" [ label="use backend API" ]
"DespotifyBackend" -> "AlsaMixer" [ label="create and use mixer API" ]
"DespotifyBackend" -> "spytify" [ label="use Python wrapper" ]
"spytify" -> "despotify" [ label="use C library" ]
"AlsaMixer" -> "alsaaudio" [ label="use Python library" ]
Thread/process communication
============================
- Everything starts with ``Main``.
- ``Main`` creates a ``Core`` process which runs the frontend, backend, and
mixer.
- Mixers *may* create an additional process for communication with external
devices, like ``NadTalker`` in this example.
- Backend libraries *may* have threads of their own, like ``despotify`` here
which has additional threads in the ``Core`` process.
- ``Server`` part currently runs in the same process and thread as ``Main``.
- ``Client`` is some external client talking to ``Server`` over a socket.
.. image:: /_static/thread_communication.png

View File

@ -0,0 +1,58 @@
*******
Roadmap
*******
This is the current roadmap and collection of wild ideas for future Mopidy
development.
Scope for the first release
===========================
This was was the plan written down when we started developing Mopidy, and we
still keep quite close to it:
To limit scope, we will start by implementing an MPD server which only
supports Spotify, and not playback of files from disk. We will make Mopidy
modular, so we can extend it with other backends in the future, like file
playback and other online music services such as Last.fm.
Stuff we really want to do, but just not right now
==================================================
- Replace libspotify with `openspotify
<http://github.com/noahwilliamsson/openspotify>`_ for the
``LibspotifyBackend``.
- A backend for playback from local disk. Quite a bit of work on a `gstreamer
<http://gstreamer.freedesktop.org/>`_ backend has already been done by Thomas
Adamcik.
- Support multiple backends at the same time. It would be really nice to have
tracks from local disk and Spotify tracks in the same playlist.
- **[Done]** Package Mopidy as a `Python package
<http://guide.python-distribute.org/>`_.
- **[Done]** Get a build server, i.e. `Hudson <http://hudson-ci.org/>`_, up and
running which runs our test suite on all relevant platforms (Ubuntu, OS X,
etc.) and creates nightly packages (see next items).
- Create `Debian packages <http://www.debian.org/doc/maint-guide/>`_ of all our
dependencies and Mopidy itself (hosted in our own Debian repo until we get
stuff into the various distros) to make Debian/Ubuntu installation a breeze.
- Create `Homebrew <http://mxcl.github.com/homebrew/>`_ recipies for all our
dependencies and Mopidy itself to make OS X installation a breeze.
Crazy stuff we had to write down somewhere
==========================================
- Add or create a new frontend protocol other than MPD. The MPD protocol got
quite a bit of legacy and it is badly documented. The amount of available
client implementations is MPD's big win.
- Add support for storing (Spotify) music to disk.
- Add support for serving the music as an `Icecast <http://www.icecast.org/>`_
stream instead of playing it locally.
- Integrate with `Squeezebox <http://www.logitechsqueezebox.com/>`_ in some
way.
- AirPort Express support, like in
`PulseAudio <http://git.0pointer.de/?p=pulseaudio.git;a=blob;f=src/modules/raop/raop_client.c;hb=HEAD>`_.
- **[Done]** NAD/Denon amplifier mixer through their RS-232 connection.
- DNLA and/or UPnP support.

View File

@ -6,9 +6,11 @@ Contents
.. toctree::
:maxdepth: 3
installation/index
changes
installation
development
development/index
api/index
authors
Indices and tables
==================

View File

@ -1,135 +0,0 @@
************
Installation
************
Mopidy itself is a breeze to install, as it just requires a standard Python
installation. The libraries we depend on to connect to the Spotify service is
far more tricky to get working for the time being. Until installation of these
libraries are either well documented by their developers, or the libraries are
packaged for various Linux distributions, we will supply our own installation
guides here.
Dependencies
============
* Python >= 2.5
* Dependencies for at least one Mopidy backend:
* :ref:`despotify`
* :ref:`libspotify`
.. _despotify:
despotify backend
=================
To use the despotify backend, you first need to install despotify and spytify.
*This backend requires a Spotify premium account.*
Installing despotify and spytify
--------------------------------
Install despotify's dependencies. At Debian/Ubuntu systems::
sudo aptitude install libssl-dev zlib1g-dev libvorbis-dev \
libtool libncursesw5-dev libao-dev
Check out revision 503 of the despotify source code::
svn co https://despotify.svn.sourceforge.net/svnroot/despotify@503 despotify
Build and install despotify::
cd despotify/src/
make
sudo make install
Build and install spytify::
cd despotify/src/bindings/python/
make
sudo make install
To validate that everything is working, run the ``test.py`` script which is
distributed with spytify::
python test.py
The test script should ask for your username and password (which must be for a
Spotify Premium account), ask for a search query, list all your playlists with
tracks, play 10s from a random song from the search result, pause for two
seconds, play for five more seconds, and quit.
.. _libspotify:
libspotify backend
==================
As an alternative to the despotify backend, we are working on a libspotify
backend. To use the libspotify backend you must install libspotify and
pyspotify.
*This backend requires a Spotify premium account.*
*This backend requires you to get an application key from Spotify before use.*
Installing libspotify and pyspotify
-----------------------------------
As libspotify's installation script at the moment is somewhat broken (see this
`GetSatisfaction thread <http://getsatisfaction.com/spotify/topics/libspotify_please_fix_the_installation_script>`_
for details), it is easiest to use the libspotify files bundled with pyspotify.
The files bundled with pyspotify are for 64-bit, so if you run a 32-bit OS, you
must get libspotify from https://developer.spotify.com/en/libspotify/.
Install pyspotify's dependencies. At Debian/Ubuntu systems::
sudo aptitude install python-alsaaudio
Check out the pyspotify code, and install it::
git clone git://github.com/winjer/pyspotify.git
cd pyspotify
export LD_LIBRARY_PATH=$PWD/lib
sudo python setup.py develop
Apply for an application key at
https://developer.spotify.com/en/libspotify/application-key, download the
binary version, and place the file at ``pyspotify/spotify_appkey.key``.
Test your libspotify setup::
./example1.py -u USERNAME -p PASSWORD
Until Spotify fixes their installation script, you'll have to set
``LD_LIBRARY_PATH`` every time you are going to use libspotify (in other words
before starting Mopidy).
Running Mopidy
==============
Create a file name ``local_settings.py`` in the same directory as
``settings.py``. Enter your Spotify Premium account's username and password
into the file, like this::
SPOTIFY_USERNAME = u'myusername'
SPOTIFY_PASSWORD = u'mysecret'
Currently the despotify backend is the default. If you want to use the
libspotify backend, copy the Spotify application key to
``mopidy/spotify_appkey.key``, and add the following to
``mopidy/mopidy/local_settings.py``::
BACKEND=u'mopidy.backends.libspotify.LibspotifyBackend'
To start Mopidy, go to the root of the Mopidy project, then simply run::
python mopidy
To stop Mopidy, press ``CTRL+C``.

View File

@ -0,0 +1,79 @@
**********************
despotify installation
**********************
To use the `despotify <http://despotify.se/>`_ backend, you first need to
install despotify and spytify.
.. warning::
This backend requires a Spotify premium account.
Installing despotify
====================
*Linux:* Install despotify's dependencies. At Debian/Ubuntu systems::
sudo aptitude install libssl-dev zlib1g-dev libvorbis-dev \
libtool libncursesw5-dev libao-dev
*OS X:* In OS X you need to have `XCode
<http://developer.apple.com/tools/xcode/>`_ installed, and either `MacPorts
<http://www.macports.org/>`_ or `Homebrew <http://mxcl.github.com/homebrew/>`_.
*OS X, Homebrew:* Install dependencies::
brew install libvorbis ncursesw libao pkg-config
*OS X, MacPorts:* Install dependencies::
sudo port install libvorbis libtool ncursesw libao
*All OS:* Check out revision 503 of the despotify source code::
svn checkout https://despotify.svn.sourceforge.net/svnroot/despotify@503
*OS X, MacPorts:* Copy ``despotify/src/Makefile.local.mk.dist`` to
``despotify/src/Makefile.local.mk`` and uncomment the last two lines of the new
file so that it reads::
## If you're on Mac OS X and have installed libvorbisfile
## via 'port install ..', try uncommenting these lines
CFLAGS += -I/opt/local/include
LDFLAGS += -L/opt/local/lib
*All OS:* Build and install despotify::
cd despotify/src/
make
sudo make install
Installing spytify
==================
spytify's source comes bundled with despotify.
Build and install spytify::
cd despotify/src/bindings/python/
export PKG_CONFIG_PATH=../../lib # Needed on OS X
make
sudo make install
Testing the installation
========================
To validate that everything is working, run the ``test.py`` script which is
distributed with spytify::
python test.py
The test script should ask for your username and password (which must be for a
Spotify Premium account), ask for a search query, list all your playlists with
tracks, play 10s from a random song from the search result, pause for two
seconds, play for five more seconds, and quit.
o stop Mopidy, press ``CTRL+C``.

107
docs/installation/index.rst Normal file
View File

@ -0,0 +1,107 @@
************
Installation
************
Mopidy itself is a breeze to install, as it just requires a standard Python
2.6 or newer installation. The libraries we depend on to connect to the Spotify
service is far more tricky to get working for the time being. Until
installation of these libraries are either well documented by their developers,
or the libraries are packaged for various Linux distributions, we will supply
our own installation guides.
Dependencies
============
.. toctree::
:hidden:
despotify
libspotify
- Python >= 2.6
- Dependencies for at least one Mopidy mixer:
- AlsaMixer (Linux only)
- pyalsaaudio >= 0.2 (Debian/Ubuntu package: python-alsaaudio)
- OsaMixer (OS X only)
- Nothing needed.
- Dependencies for at least one Mopidy backend:
- DespotifyBackend (Linux and OS X)
- see :doc:`despotify`
- LibspotifyBackend (Linux only)
- see :doc:`libspotify`
Install latest release
======================
To install the currently latest release of Mopidy using ``pip``::
sudo aptitude install python-pip # On Ubuntu/Debian
sudo brew install pip # On OS X
sudo pip install Mopidy
To later upgrade to the latest release::
sudo pip install -U Mopidy
If you for some reason can't use ``pip``, try ``easy_install``.
Install development version
===========================
If you want to follow Mopidy development closer, you may install the
development version of Mopidy::
sudo aptitude install git-core # On Ubuntu/Debian
sudo brew install git # On OS X
git clone git://github.com/jodal/mopidy.git
cd mopidy/
sudo python setup.py install
To later update to the very latest version::
cd mopidy/
git pull
sudo python setup.py install
For an introduction to ``git``, please visit `git-scm.com
<http://git-scm.com/>`_.
Spotify settings
================
Create a file named ``settings.py`` in the directory ``~/.mopidy/``. Enter
your Spotify Premium account's username and password into the file, like this::
SPOTIFY_USERNAME = u'myusername'
SPOTIFY_PASSWORD = u'mysecret'
For a full list of available settings, see :mod:`mopidy.settings`.
Running Mopidy
==============
To start Mopidy, simply open a terminal and run::
mopidy
When Mopidy says ``MPD server running at [localhost]:6600`` it's ready to
accept connections by any MPD client. You can find a list of tons of MPD
clients at http://mpd.wikia.com/wiki/Clients. We use Sonata, GMPC, ncmpc, and
ncmpcpp during development. The first two are GUI clients, while the last two
are terminal clients.
To stop Mopidy, press ``CTRL+C``.

View File

@ -0,0 +1,67 @@
***********************
libspotify installation
***********************
As an alternative to the despotify backend, we are working on a
`libspotify <http://developer.spotify.com/en/libspotify/overview/>`_ backend.
To use the libspotify backend you must install libspotify and
`pyspotify <http://github.com/winjer/pyspotify>`_.
.. warning::
This backend requires a Spotify premium account, and it requires you to get
an application key from Spotify before use.
Installing libspotify
=====================
As libspotify's installation script at the moment is somewhat broken (see this
`GetSatisfaction thread <http://getsatisfaction.com/spotify/topics/libspotify_please_fix_the_installation_script>`_
for details), it is easiest to use the libspotify files bundled with pyspotify.
The files bundled with pyspotify are for 64-bit, so if you run a 32-bit OS, you
must get libspotify from https://developer.spotify.com/en/libspotify/.
Installing pyspotify
====================
Install pyspotify's dependencies. At Debian/Ubuntu systems::
sudo aptitude install python-alsaaudio
Check out the pyspotify code, and install it::
git clone git://github.com/winjer/pyspotify.git
cd pyspotify
export LD_LIBRARY_PATH=$PWD/lib
sudo python setup.py develop
Apply for an application key at
https://developer.spotify.com/en/libspotify/application-key, download the
binary version, and place the file at ``pyspotify/spotify_appkey.key``.
Testing the installation
========================
Test your libspotify setup::
examples/example1.py -u USERNAME -p PASSWORD
.. note::
Until Spotify fixes their installation script, you'll have to set
``LD_LIBRARY_PATH`` every time you are going to use libspotify (in other
words before starting Mopidy).
Setting up Mopidy to use libspotify
===================================
Currently :mod:`mopidy.backends.despotify` is the default
backend. If you want to use :mod:`mopidy.backends.libspotify`
instead, copy the Spotify application key to ``~/.mopidy/spotify_appkey.key``,
and add the following to ``~/.mopidy/settings.py``::
BACKENDS = (u'mopidy.backends.libspotify.LibspotifyBackend',)

View File

@ -1,21 +1,34 @@
from mopidy import settings
from mopidy.exceptions import ConfigError
from mopidy import settings as raw_settings
def get_version():
return u'0'
return u'0.1.0a0'
def get_mpd_protocol_version():
return u'0.15.0'
return u'0.16.0'
class Config(object):
class MopidyException(Exception):
def __init__(self, message):
self.message = message
@property
def message(self):
"""Reimplement message field that was deprecated in Python 2.6"""
return self._message
@message.setter
def message(self, message):
self._message = message
class SettingsError(MopidyException):
pass
class Settings(object):
def __getattr__(self, attr):
if not hasattr(settings, attr):
raise ConfigError(u'Setting "%s" is not set.' % attr)
value = getattr(settings, attr)
if attr.isupper() and not hasattr(raw_settings, attr):
raise SettingsError(u'Setting "%s" is not set.' % attr)
value = getattr(raw_settings, attr)
if type(value) != bool and not value:
raise ConfigError(u'Setting "%s" is empty.' % attr)
if type(value) == unicode:
value = value.encode('utf-8')
raise SettingsError(u'Setting "%s" is empty.' % attr)
return value
config = Config()
settings = Settings()

View File

@ -1,23 +1,39 @@
import asyncore
import logging
import multiprocessing
import optparse
import os
import sys
sys.path.insert(0,
os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
from mopidy import config
from mopidy.exceptions import ConfigError
from mopidy.mpd.server import MpdServer
from mopidy import get_version, settings, SettingsError
from mopidy.process import CoreProcess
from mopidy.utils import get_class, get_or_create_dotdir
logger = logging.getLogger('mopidy')
logger = logging.getLogger('mopidy.main')
def main():
_setup_logging(2)
backend = _get_backend(config.BACKEND)
MpdServer(backend=backend)
options, args = _parse_options()
_setup_logging(options.verbosity_level)
get_or_create_dotdir('~/.mopidy/')
core_queue = multiprocessing.Queue()
get_class(settings.SERVER)(core_queue)
core = CoreProcess(core_queue)
core.start()
asyncore.loop()
def _parse_options():
parser = optparse.OptionParser(version='Mopidy %s' % get_version())
parser.add_option('-q', '--quiet',
action='store_const', const=0, dest='verbosity_level',
help='less output (warning level)')
parser.add_option('-v', '--verbose',
action='store_const', const=2, dest='verbosity_level',
help='more output (debug level)')
return parser.parse_args()
def _setup_logging(verbosity_level):
if verbosity_level == 0:
level = logging.WARNING
@ -25,24 +41,17 @@ def _setup_logging(verbosity_level):
level = logging.DEBUG
else:
level = logging.INFO
logging.basicConfig(
format=config.CONSOLE_LOG_FORMAT,
level=level,
)
def _get_backend(name):
module_name = name[:name.rindex('.')]
class_name = name[name.rindex('.') + 1:]
logger.info('Loading: %s from %s', class_name, module_name)
module = __import__(module_name, globals(), locals(), [class_name], -1)
class_object = getattr(module, class_name)
instance = class_object()
return instance
logging.basicConfig(format=settings.CONSOLE_LOG_FORMAT, level=level)
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
sys.exit('\nInterrupted by user')
except ConfigError, e:
sys.exit('%s' % e)
logger.info(u'Interrupted by user')
sys.exit(0)
except SettingsError, e:
logger.error(e)
sys.exit(1)
except SystemExit, e:
logger.error(e)
sys.exit(1)

View File

@ -1,205 +1,587 @@
from copy import copy
import logging
import random
import time
from mopidy.exceptions import MpdNotImplemented
from mopidy import settings
from mopidy.models import Playlist
from mopidy.utils import get_class
logger = logging.getLogger('backends.base')
logger = logging.getLogger('mopidy.backends.base')
class BaseBackend(object):
def __init__(self, core_queue=None, mixer=None):
self.core_queue = core_queue
if mixer is not None:
self.mixer = mixer
else:
self.mixer = get_class(settings.MIXER)()
#: A :class:`multiprocessing.Queue` which can be used by e.g. library
#: callbacks to send messages to the core.
core_queue = None
#: The current playlist controller. An instance of
#: :class:`BaseCurrentPlaylistController`.
current_playlist = None
#: The library controller. An instance of :class:`BaseLibraryController`.
library = None
#: The sound mixer. An instance of :class:`mopidy.mixers.BaseMixer`.
mixer = None
#: The playback controller. An instance of :class:`BasePlaybackController`.
playback = None
#: The stored playlists controller. An instance of
#: :class:`BaseStoredPlaylistsController`.
stored_playlists = None
#: List of URI prefixes this backend can handle.
uri_handlers = []
def destroy(self):
self.playback.destroy()
class BaseCurrentPlaylistController(object):
"""
:param backend: backend the controller is a part of
:type backend: :class:`BaseBackend`
"""
#: The current playlist version. Integer which is increased every time the
#: current playlist is changed. Is not reset before the MPD server is
#: restarted.
version = 0
def __init__(self, backend):
self.backend = backend
self.version = 0
self.playlist = Playlist()
@property
def playlist(self):
return self._playlist
"""The currently loaded :class:`mopidy.models.Playlist`."""
return copy(self._playlist)
@playlist.setter
def playlist(self, playlist):
self._playlist = playlist
def playlist(self, new_playlist):
self._playlist = new_playlist
self.version += 1
self.backend.playback.new_playlist_loaded_callback()
def add(self, uri, at_position=None):
raise NotImplementedError
def add(self, track, at_position=None):
"""
Add the track to the end of, or at the given position in the current
playlist.
:param track: track to add
:type track: :class:`mopidy.models.Track`
:param at_position: position in current playlist to add track
:type at_position: int or :class:`None`
"""
tracks = self.playlist.tracks
if at_position:
tracks.insert(at_position, track)
else:
tracks.append(track)
self.playlist = self.playlist.with_(tracks=tracks)
def clear(self):
"""Clear the current playlist."""
self.backend.playback.stop()
self.backend.playback.current_track = None
self.playlist = Playlist()
def get_by_id(self, id):
matches = filter(lambda t: t.id == id, self.playlist.tracks)
if matches:
return matches[0]
else:
raise KeyError('Track with ID "%s" not found' % id)
def get(self, **criteria):
"""
Get track by given criterias from current playlist.
def get_by_url(self, uri):
matches = filter(lambda t: t.uri == uri, self.playlist.tracks)
if matches:
Raises :exc:`LookupError` if a unique match is not found.
Examples::
get(id=1) # Returns track with ID 1
get(uri='xyz') # Returns track with URI 'xyz'
get(id=1, uri='xyz') # Returns track with ID 1 and URI 'xyz'
:param criteria: on or more criteria to match by
:type criteria: dict
:rtype: :class:`mopidy.models.Track`
"""
matches = self._playlist.tracks
for (key, value) in criteria.iteritems():
matches = filter(lambda t: getattr(t, key) == value, matches)
if len(matches) == 1:
return matches[0]
criteria_string = ', '.join(
['%s=%s' % (k, v) for (k, v) in criteria.iteritems()])
if len(matches) == 0:
raise LookupError(u'"%s" match no tracks' % criteria_string)
else:
raise KeyError('Track with URI "%s" not found' % uri)
raise LookupError(u'"%s" match multiple tracks' % criteria_string)
def load(self, playlist):
"""
Replace the current playlist with the given playlist.
:param playlist: playlist to load
:type playlist: :class:`mopidy.models.Playlist`
"""
self.playlist = playlist
def move(self, start, end, to_position):
"""
Move the tracks in the slice ``[start:end]`` to ``to_position``.
:param start: position of first track to move
:type start: int
:param end: position after last track to move
:type end: int
:param to_position: new position for the tracks
:type to_position: int
"""
tracks = self.playlist.tracks
if start == end:
end += 1
new_tracks = tracks[:start] + tracks[end:]
for track in tracks[start:end]:
new_tracks.insert(to_position, track)
to_position += 1
self.playlist = self.playlist.with_(tracks=new_tracks)
self.playlist = Playlist(tracks=new_tracks)
def remove(self, track):
"""
Remove the track from the current playlist.
def remove(self,track):
tracks = filter(lambda t: t != track, self.playlist.tracks)
self.playlist = Playlist(tracks=tracks)
:param track: track to remove
:type track: :class:`mopidy.models.Track`
"""
tracks = self.playlist.tracks
position = tracks.index(track)
del tracks[position]
self.playlist = self.playlist.with_(tracks=tracks)
def shuffle(self, start=None, end=None):
tracks = self.playlist.tracks
"""
Shuffles the entire playlist. If ``start`` and ``end`` is given only
shuffles the slice ``[start:end]``.
:param start: position of first track to shuffle
:type start: int or :class:`None`
:param end: position after last track to shuffle
:type end: int or :class:`None`
"""
tracks = self.playlist.tracks
before = tracks[:start or 0]
shuffled = tracks[start:end]
after = tracks[end or len(tracks):]
random.shuffle(shuffled)
self.playlist = self.playlist.with_(tracks=before+shuffled+after)
self.playlist = Playlist(tracks=before+shuffled+after)
class BasePlaybackController(object):
PAUSED = 'paused'
PLAYING = 'playing'
STOPPED = 'stopped'
class BaseLibraryController(object):
"""
:param backend: backend the controller is a part of
:type backend: :class:`BaseBackend`
"""
state = STOPPED
repeat = False
random = False
consume = False
volume = None
def __init__(self, backend):
self.backend = backend
self.current_track = None
self._shuffled = []
self._first_shuffle = True
def play(self, id=None, position=None):
def find_exact(self, type, query):
"""
Find tracks in the library where ``type`` matches ``query`` exactly.
:param type: 'track', 'artist', or 'album'
:type type: string
:param query: the search query
:type query: string
:rtype: :class:`mopidy.models.Playlist`
"""
raise NotImplementedError
def stop(self):
def lookup(self, uri):
"""
Lookup track with given URI.
:param uri: track URI
:type uri: string
:rtype: :class:`mopidy.models.Track`
"""
raise NotImplementedError
def new_playlist_loaded_callback(self):
self.current_track = None
def refresh(self, uri=None):
"""
Refresh library. Limit to URI and below if an URI is given.
if self.state == self.PLAYING:
self.play()
elif self.state == self.PAUSED:
self.stop()
def next(self):
current_track = self.current_track
if not self.next_track:
self.stop()
else:
self.play(self.next_track)
if self.consume:
self.backend.current_playlist.remove(current_track)
def previous(self):
if self.previous_track:
self.play(self.previous_track)
def pause(self):
:param uri: directory or track URI
:type uri: string
"""
raise NotImplementedError
def resume(self):
def search(self, type, query):
"""
Search the library for tracks where ``type`` contains ``query``.
:param type: 'track', 'artist', 'album', 'uri', and 'any'
:type type: string
:param query: the search query
:type query: string
:rtype: :class:`mopidy.models.Playlist`
"""
raise NotImplementedError
def seek(self, time_position):
raise NotImplementedError
class BasePlaybackController(object):
"""
:param backend: backend the controller is a part of
:type backend: :class:`BaseBackend`
"""
#: Constant representing the paused state.
PAUSED = u'paused'
#: Constant representing the playing state.
PLAYING = u'playing'
#: Constant representing the stopped state.
STOPPED = u'stopped'
#: :class:`True`
#: Tracks are removed from the playlist when they have been played.
#: :class:`False`
#: Tracks are not removed from the playlist.
consume = False
#: The currently playing or selected :class:`mopidy.models.Track`.
current_track = None
#: :class:`True`
#: Tracks are selected at random from the playlist.
#: :class:`False`
#: Tracks are played in the order of the playlist.
random = False
#: :class:`True`
#: The current track is played repeatedly.
#: :class:`False`
#: The current track is played once.
repeat = False
#: :class:`True`
#: Playback is stopped after current song, unless in repeat mode.
#: :class:`False`
#: Playback continues after current song.
single = False
def __init__(self, backend):
self.backend = backend
self._state = self.STOPPED
@property
def next_track(self):
playlist = self.backend.current_playlist.playlist
if not playlist.tracks:
return None
if self.random and not self._shuffled:
if self.repeat or self._first_shuffle:
self._shuffled = playlist.tracks
random.shuffle(self._shuffled)
self._first_shuffle = self.repeat
if self._shuffled:
return self._shuffled[0]
if self.current_track is None:
return playlist.tracks[0]
if self.repeat:
position = (self.playlist_position + 1) % len(playlist.tracks)
return playlist.tracks[position]
try:
return playlist.tracks[self.playlist_position + 1]
except IndexError:
return None
@property
def previous_track(self):
playlist = self.backend.current_playlist.playlist
if self.repeat or self.consume or self.random:
return self.current_track
"""
The next :class:`mopidy.models.Track` in the playlist.
For normal playback this is the next track in the playlist. If repeat
is enabled the next track can loop around the playlist. When random is
enabled this should be a random track, all tracks should be played once
before the list repeats.
"""
if self.current_track is None:
return None
if self.playlist_position - 1 < 0:
return None
try:
return playlist.tracks[self.playlist_position - 1]
return self.backend.current_playlist.playlist.tracks[
self.playlist_position + 1]
except IndexError:
return None
@property
def playlist_position(self):
playlist = self.backend.current_playlist.playlist
"""The position in the current playlist."""
if self.current_track is None:
return None
try:
return playlist.tracks.index(self.current_track)
return self.backend.current_playlist.playlist.tracks.index(
self.current_track)
except ValueError:
return None
@property
def previous_track(self):
"""
The previous :class:`mopidy.models.Track` in the playlist.
For normal playback this is the next track in the playlist. If random
and/or consume is enabled it should return the current track instead.
"""
if self.current_track is None:
return None
try:
return self.backend.current_playlist.playlist.tracks[
self.playlist_position - 1]
except IndexError:
return None
@property
def state(self):
"""
The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or
:attr:`STOPPED`.
Possible states and transitions:
.. digraph:: state_transitions
"STOPPED" -> "PLAYING" [ label="play" ]
"PLAYING" -> "STOPPED" [ label="stop" ]
"PLAYING" -> "PAUSED" [ label="pause" ]
"PLAYING" -> "PLAYING" [ label="play" ]
"PAUSED" -> "PLAYING" [ label="resume" ]
"PAUSED" -> "STOPPED" [ label="stop" ]
"""
return self._state
@state.setter
def state(self, new_state):
(old_state, self._state) = (self.state, new_state)
logger.debug(u'Changing state: %s -> %s', old_state, new_state)
if (old_state in (self.PLAYING, self.STOPPED)
and new_state == self.PLAYING):
self._play_time_start()
elif old_state == self.PLAYING and new_state == self.PAUSED:
self._play_time_pause()
elif old_state == self.PAUSED and new_state == self.PLAYING:
self._play_time_resume()
@property
def time_position(self):
"""Time position in milliseconds."""
if self.state == self.PLAYING:
time_since_started = (self._current_wall_time -
self._play_time_started)
return self._play_time_accumulated + time_since_started
elif self.state == self.PAUSED:
return self._play_time_accumulated
elif self.state == self.STOPPED:
return 0
def _play_time_start(self):
self._play_time_accumulated = 0
self._play_time_started = self._current_wall_time
def _play_time_pause(self):
time_since_started = self._current_wall_time - self._play_time_started
self._play_time_accumulated += time_since_started
def _play_time_resume(self):
self._play_time_started = self._current_wall_time
@property
def _current_wall_time(self):
return int(time.time() * 1000)
@property
def volume(self):
"""
The audio volume as an int in the range [0, 100].
:class:`None` if unknown.
"""
return self.backend.mixer.volume
@volume.setter
def volume(self, volume):
self.backend.mixer.volume = volume
def end_of_track_callback(self):
"""Tell the playback controller that end of track is reached."""
if self.next_track is not None:
self.next()
else:
self.stop()
def new_playlist_loaded_callback(self):
"""Tell the playback controller that a new playlist has been loaded."""
self.current_track = None
if self.state == self.PLAYING:
if self.backend.current_playlist.playlist.length > 0:
self.play(self.backend.current_playlist.playlist.tracks[0])
else:
self.stop()
def next(self):
"""Play the next track."""
if self.next_track is not None and self._next(self.next_track):
self.current_track = self.next_track
self.state = self.PLAYING
def _next(self, track):
return self._play(track)
def pause(self):
"""Pause playback."""
if self.state == self.PLAYING and self._pause():
self.state = self.PAUSED
def _pause(self):
raise NotImplementedError
def destroy(self):
pass
def play(self, track=None):
"""
Play the given track or the currently active track.
:param track: track to play
:type track: :class:`mopidy.models.Track` or :class:`None`
"""
if self.state == self.PAUSED and track is None:
return self.resume()
if track is not None and self._play(track):
self.current_track = track
self.state = self.PLAYING
def _play(self, track):
raise NotImplementedError
def previous(self):
"""Play the previous track."""
if (self.previous_track is not None
and self._previous(self.previous_track)):
self.current_track = self.previous_track
self.state = self.PLAYING
def _previous(self, track):
return self._play(track)
def resume(self):
"""If paused, resume playing the current track."""
if self.state == self.PAUSED and self._resume():
self.state = self.PLAYING
def _resume(self):
raise NotImplementedError
def seek(self, time_position):
"""
Seeks to time position given in milliseconds.
:param time_position: time position in milliseconds
:type time_position: int
"""
raise NotImplementedError
def stop(self):
"""Stop playing."""
if self.state != self.STOPPED and self._stop():
self.state = self.STOPPED
def _stop(self):
raise NotImplementedError
class BaseStoredPlaylistsController(object):
"""
:param backend: backend the controller is a part of
:type backend: :class:`BaseBackend`
"""
def __init__(self, backend):
self.backend = backend
self._playlists = []
@property
def playlists(self):
"""List of :class:`mopidy.models.Playlist`."""
return copy(self._playlists)
@playlists.setter
def playlists(self, playlists):
self._playlists = playlists
def create(self, name):
"""
Create a new playlist.
:param name: name of the new playlist
:type name: string
:rtype: :class:`mopidy.models.Playlist`
"""
raise NotImplementedError
def delete(self, playlist):
"""
Delete playlist.
:param playlist: the playlist to delete
:type playlist: :class:`mopidy.models.Playlist`
"""
raise NotImplementedError
def get(self, **criteria):
"""
Get playlist by given criterias from the set of stored playlists.
Raises :exc:`LookupError` if a unique match is not found.
Examples::
get(name='a') # Returns track with name 'a'
get(uri='xyz') # Returns track with URI 'xyz'
get(name='a', uri='xyz') # Returns track with name 'a' and URI 'xyz'
:param criteria: on or more criteria to match by
:type criteria: dict
:rtype: :class:`mopidy.models.Playlist`
"""
matches = self._playlists
for (key, value) in criteria.iteritems():
matches = filter(lambda p: getattr(p, key) == value, matches)
if len(matches) == 1:
return matches[0]
criteria_string = ', '.join(
['%s=%s' % (k, v) for (k, v) in criteria.iteritems()])
if len(matches) == 0:
raise LookupError('"%s" match no playlists' % criteria_string)
else:
raise LookupError('"%s" match multiple playlists' % criteria_string)
def lookup(self, uri):
"""
Lookup playlist with given URI in both the set of stored playlists and
in any other playlist sources.
:param uri: playlist URI
:type uri: string
:rtype: :class:`mopidy.models.Playlist`
"""
raise NotImplementedError
def refresh(self):
"""Refresh stored playlists."""
raise NotImplementedError
def rename(self, playlist, new_name):
"""
Rename playlist.
:param playlist: the playlist
:type playlist: :class:`mopidy.models.Playlist`
:param new_name: the new name
:type new_name: string
"""
raise NotImplementedError
def save(self, playlist):
"""
Save the playlist to the set of stored playlists.
:param playlist: the playlist
:type playlist: :class:`mopidy.models.Playlist`
"""
raise NotImplementedError
def search(self, query):
"""
Search for playlists whose name contains ``query``.
:param query: query to search for
:type query: string
:rtype: list of :class:`mopidy.models.Playlist`
"""
return filter(lambda p: query in p.name, self._playlists)

View File

@ -4,121 +4,178 @@ import sys
import spytify
from mopidy import config
from mopidy.backends import BaseBackend
from mopidy import settings
from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController,
BaseLibraryController, BasePlaybackController,
BaseStoredPlaylistsController)
from mopidy.models import Artist, Album, Track, Playlist
from mopidy.utils import spotify_uri_to_int
logger = logging.getLogger(u'backends.despotify')
logger = logging.getLogger('mopidy.backends.despotify')
ENCODING = 'utf-8'
class DespotifyBackend(BaseBackend):
"""
A Spotify backend which uses the open source `despotify library
<http://despotify.se/>`_.
`spytify <http://despotify.svn.sourceforge.net/viewvc/despotify/src/bindings/python/>`_
is the Python bindings for the despotify library. It got litle
documentation, but a couple of examples are available.
**Issues**
- r503: Sometimes segfaults when traversing stored playlists, their tracks,
artists, and albums. As it is not predictable, it may be a concurrency
issue.
- r503: Segfaults when looking up playlists, both your own lists and other
peoples shared lists. To reproduce::
>>> import spytify # doctest: +SKIP
>>> s = spytify.Spytify('alice', 'secret') # doctest: +SKIP
>>> s.lookup('spotify:user:klette:playlist:5rOGYPwwKqbAcVX8bW4k5V')
... # doctest: +SKIP
Segmentation fault
"""
def __init__(self, *args, **kwargs):
super(DespotifyBackend, self).__init__(*args, **kwargs)
logger.info(u'Connecting to Spotify')
self.spotify = spytify.Spytify(
config.SPOTIFY_USERNAME, config.SPOTIFY_PASSWORD)
self.cache_stored_playlists()
self.current_playlist = DespotifyCurrentPlaylistController(backend=self)
self.library = DespotifyLibraryController(backend=self)
self.playback = DespotifyPlaybackController(backend=self)
self.stored_playlists = DespotifyStoredPlaylistsController(backend=self)
self.uri_handlers = [u'spotify:', u'http://open.spotify.com/']
self.spotify = self._connect()
self.stored_playlists.refresh()
def cache_stored_playlists(self):
def _connect(self):
logger.info(u'Connecting to Spotify')
try:
return DespotifySessionManager(
settings.SPOTIFY_USERNAME.encode(ENCODING),
settings.SPOTIFY_PASSWORD.encode(ENCODING),
core_queue=self.core_queue)
except spytify.SpytifyError as e:
logger.exception(e)
sys.exit(1)
class DespotifyCurrentPlaylistController(BaseCurrentPlaylistController):
pass
class DespotifyLibraryController(BaseLibraryController):
def lookup(self, uri):
track = self.backend.spotify.lookup(uri.encode(ENCODING))
return DespotifyTranslator.to_mopidy_track(track)
def search(self, type, what):
if type == u'track':
type = u'title'
if type == u'any':
query = what
else:
query = u'%s:%s' % (type, what)
result = self.backend.spotify.search(query.encode(ENCODING))
if (result is None or result.playlist.tracks[0].get_uri() ==
'spotify:track:0000000000000000000000'):
return Playlist()
return DespotifyTranslator.to_mopidy_playlist(result.playlist)
find_exact = search
class DespotifyPlaybackController(BasePlaybackController):
def _pause(self):
self.backend.spotify.pause()
return True
def _play(self, track):
self.backend.spotify.play(self.backend.spotify.lookup(track.uri))
return True
def _resume(self):
self.backend.spotify.resume()
return True
def _stop(self):
self.backend.spotify.stop()
return True
class DespotifyStoredPlaylistsController(BaseStoredPlaylistsController):
def refresh(self):
logger.info(u'Caching stored playlists')
playlists = []
for spotify_playlist in self.spotify.stored_playlists:
playlists.append(self._to_mopidy_playlist(spotify_playlist))
for spotify_playlist in self.backend.spotify.stored_playlists:
playlists.append(
DespotifyTranslator.to_mopidy_playlist(spotify_playlist))
self._playlists = playlists
logger.debug(u'Available playlists: %s',
u', '.join([u'<%s>' % p.name for p in self._playlists]))
u', '.join([u'<%s>' % p.name for p in self.playlists]))
logger.info(u'Done caching stored playlists')
# Model translation
def _to_mopidy_id(self, spotify_uri):
return 0 # TODO
class DespotifyTranslator(object):
@classmethod
def to_mopidy_id(cls, spotify_uri):
return spotify_uri_to_int(spotify_uri)
def _to_mopidy_artist(self, spotify_artist):
@classmethod
def to_mopidy_artist(cls, spotify_artist):
return Artist(
uri=spotify_artist.get_uri(),
name=spotify_artist.name.decode(ENCODING)
)
def _to_mopidy_album(self, spotify_album_name):
@classmethod
def to_mopidy_album(cls, spotify_album_name):
return Album(name=spotify_album_name.decode(ENCODING))
def _to_mopidy_track(self, spotify_track):
@classmethod
def to_mopidy_track(cls, spotify_track):
if dt.MINYEAR <= int(spotify_track.year) <= dt.MAXYEAR:
date = dt.date(spotify_track.year, 1, 1)
else:
date = None
return Track(
uri=spotify_track.get_uri(),
title=spotify_track.title.decode(ENCODING),
artists=[self._to_mopidy_artist(a) for a in spotify_track.artists],
album=self._to_mopidy_album(spotify_track.album),
name=spotify_track.title.decode(ENCODING),
artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists],
album=cls.to_mopidy_album(spotify_track.album),
track_no=spotify_track.tracknumber,
date=date,
length=spotify_track.length,
id=self._to_mopidy_id(spotify_track.get_uri()),
bitrate=320,
id=cls.to_mopidy_id(spotify_track.get_uri()),
)
def _to_mopidy_playlist(self, spotify_playlist):
@classmethod
def to_mopidy_playlist(cls, spotify_playlist):
return Playlist(
uri=spotify_playlist.get_uri(),
name=spotify_playlist.name.decode(ENCODING),
tracks=[self._to_mopidy_track(t) for t in spotify_playlist.tracks],
tracks=[cls.to_mopidy_track(t) for t in spotify_playlist.tracks],
)
# Play control
def _next(self):
self._current_song_pos += 1
self.spotify.play(self.spotify.lookup(self._current_track.uri))
return True
class DespotifySessionManager(spytify.Spytify):
DESPOTIFY_NEW_TRACK = 1
DESPOTIFY_TIME_TELL = 2
DESPOTIFY_END_OF_PLAYLIST = 3
DESPOTIFY_TRACK_PLAY_ERROR = 4
def _pause(self):
self.spotify.pause()
return True
def __init__(self, *args, **kwargs):
kwargs['callback'] = self.callback
self.core_queue = kwargs.pop('core_queue')
super(DespotifySessionManager, self).__init__(*args, **kwargs)
def _play(self):
if self._current_track is not None:
self.spotify.play(self.spotify.lookup(self._current_track.uri))
return True
else:
return False
def _play_id(self, songid):
self._current_song_pos = songid # XXX
self.spotify.play(self.spotify.lookup(self._current_track.uri))
return True
def _play_pos(self, songpos):
self._current_song_pos = songpos
self.spotify.play(self.spotify.lookup(self._current_track.uri))
return True
def _previous(self):
self._current_song_pos -= 1
self.spotify.play(self.spotify.lookup(self._current_track.uri))
return True
def _resume(self):
self.spotify.resume()
return True
def _stop(self):
self.spotify.stop()
return True
# Status querying
def status_bitrate(self):
return 320
def url_handlers(self):
return [u'spotify:', u'http://open.spotify.com/']
# Music database
def search(self, type, what):
query = u'%s:%s' % (type, what)
result = self.spotify.search(query.encode(ENCODING))
if result is not None:
return self._to_mopidy_playlist(result.playlist).mpd_format()
def callback(self, signal, data):
if signal == self.DESPOTIFY_END_OF_PLAYLIST:
logger.debug('Despotify signalled end of playlist')
self.core_queue.put({'command': 'end_of_track'})
elif signal == self.DESPOTIFY_TRACK_PLAY_ERROR:
logger.error('Despotify signalled track play error')

View File

@ -1,25 +1,48 @@
from mopidy.backends import BaseBackend
from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController,
BasePlaybackController, BaseLibraryController,
BaseStoredPlaylistsController)
from mopidy.models import Playlist
class DummyBackend(BaseBackend):
"""
A backend which implements the backend API in the simplest way possible.
Used in tests of the frontends.
Handles URIs starting with ``dummy:``.
"""
def __init__(self, *args, **kwargs):
super(DummyBackend, self).__init__(*args, **kwargs)
self.current_playlist = DummyCurrentPlaylistController(backend=self)
self.library = DummyLibraryController(backend=self)
self.playback = DummyPlaybackController(backend=self)
self.stored_playlists = DummyStoredPlaylistsController(backend=self)
self.uri_handlers = [u'dummy:']
def url_handlers(self):
return [u'dummy:']
class DummyCurrentPlaylistController(BaseCurrentPlaylistController):
pass
class DummyLibraryController(BaseLibraryController):
_library = []
def lookup(self, uri):
matches = filter(lambda t: uri == t.uri, self._library)
if matches:
return matches[0]
def search(self, type, query):
return Playlist()
find_exact = search
class DummyPlaybackController(BasePlaybackController):
def _next(self):
return True
def _pause(self):
return True
def _play(self):
return True
def _play_id(self, songid):
return True
def _play_pos(self, songpos):
def _play(self, track):
return True
def _previous(self):
@ -27,3 +50,7 @@ class DummyBackend(BaseBackend):
def _resume(self):
return True
class DummyStoredPlaylistsController(BaseStoredPlaylistsController):
def search(self, query):
return [Playlist(name=query)]

View File

@ -1,121 +1,98 @@
from copy import deepcopy
import datetime as dt
import logging
import os
import multiprocessing
import threading
from spotify import Link
from spotify.manager import SpotifySessionManager
from spotify.alsahelper import AlsaController
from mopidy import config
from mopidy.backends import BaseBackend
from mopidy import get_version, settings
from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController,
BaseLibraryController, BasePlaybackController,
BaseStoredPlaylistsController)
from mopidy.models import Artist, Album, Track, Playlist
from mopidy.utils import spotify_uri_to_int
logger = logging.getLogger(u'backends.libspotify')
logger = logging.getLogger('mopidy.backends.libspotify')
ENCODING = 'utf-8'
class LibspotifyBackend(BaseBackend):
"""
A Spotify backend which uses the official `libspotify library
<http://developer.spotify.com/en/libspotify/overview/>`_.
`pyspotify <http://github.com/winjer/pyspotify/>`_ is the Python bindings
for libspotify. It got no documentation, but multiple examples are
available. Like libspotify, pyspotify's calls are mostly asynchronous.
This backend should also work with `openspotify
<http://github.com/noahwilliamsson/openspotify>`_, but we haven't tested
that yet.
**Issues**
- libspotify is badly packaged. See
http://getsatisfaction.com/spotify/topics/libspotify_please_fix_the_installation_script.
"""
def __init__(self, *args, **kwargs):
super(LibspotifyBackend, self).__init__(*args, **kwargs)
self._next_id = 0
self._id_to_uri_map = {}
self._uri_to_id_map = {}
self.current_playlist = LibspotifyCurrentPlaylistController(
backend=self)
self.library = LibspotifyLibraryController(backend=self)
self.playback = LibspotifyPlaybackController(backend=self)
self.stored_playlists = LibspotifyStoredPlaylistsController(
backend=self)
self.uri_handlers = [u'spotify:', u'http://open.spotify.com/']
self.spotify = self._connect()
def _connect(self):
logger.info(u'Connecting to Spotify')
self.spotify = LibspotifySessionManager(
config.SPOTIFY_USERNAME, config.SPOTIFY_PASSWORD, backend=self)
self.spotify.start()
spotify = LibspotifySessionManager(
settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD,
core_queue=self.core_queue)
spotify.start()
return spotify
def update_stored_playlists(self):
logger.info(u'Updating stored playlists')
playlists = []
for spotify_playlist in self.spotify.playlists:
playlists.append(self._to_mopidy_playlist(spotify_playlist))
self._playlists = playlists
logger.debug(u'Available playlists: %s',
u', '.join([u'<%s>' % p.name for p in self._playlists]))
# Model translation
class LibspotifyCurrentPlaylistController(BaseCurrentPlaylistController):
pass
def _to_mopidy_id(self, spotify_uri):
if spotify_uri in self._uri_to_id_map:
return self._uri_to_id_map[spotify_uri]
class LibspotifyLibraryController(BaseLibraryController):
def search(self, type, what):
if type is u'any':
query = what
else:
id = self._next_id
self._next_id += 1
self._id_to_uri_map[id] = spotify_uri
self._uri_to_id_map[spotify_uri] = id
return id
query = u'%s:%s' % (type, what)
my_end, other_end = multiprocessing.Pipe()
self.backend.spotify.search(query.encode(ENCODING), other_end)
my_end.poll(None)
logger.debug(u'In search method, receiving search results')
playlist = my_end.recv()
logger.debug(u'In search method, done receiving search results')
logger.debug(['%s' % t.name for t in playlist.tracks])
return playlist
def _to_mopidy_artist(self, spotify_artist):
return Artist(
uri=str(Link.from_artist(spotify_artist)),
name=spotify_artist.name().decode(ENCODING),
)
find_exact = search
def _to_mopidy_album(self, spotify_album):
# TODO pyspotify got much more data on albums than this
return Album(name=spotify_album.name().decode(ENCODING))
def _to_mopidy_track(self, spotify_track):
return Track(
uri=str(Link.from_track(spotify_track, 0)),
title=spotify_track.name().decode(ENCODING),
artists=[self._to_mopidy_artist(a)
for a in spotify_track.artists()],
album=self._to_mopidy_album(spotify_track.album()),
track_no=spotify_track.index(),
date=dt.date(spotify_track.album().year(), 1, 1),
length=spotify_track.duration(),
id=self._to_mopidy_id(str(Link.from_track(spotify_track, 0))),
)
def _to_mopidy_playlist(self, spotify_playlist):
return Playlist(
uri=str(Link.from_playlist(spotify_playlist)),
name=spotify_playlist.name().decode(ENCODING),
tracks=[self._to_mopidy_track(t) for t in spotify_playlist],
)
# Playback control
def _play_current_track(self):
self.spotify.session.load(
Link.from_string(self._current_track.uri).as_track())
self.spotify.session.play(1)
def _next(self):
self._current_song_pos += 1
self._play_current_track()
return True
class LibspotifyPlaybackController(BasePlaybackController):
def _pause(self):
# TODO
return False
def _play(self):
if self._current_track is not None:
self._play_current_track()
return True
else:
def _play(self, track):
if self.state == self.PLAYING:
self.stop()
if track.uri is None:
return False
def _play_id(self, songid):
matches = filter(lambda t: t.id == songid, self._current_playlist)
if matches:
self._current_song_pos = self._current_playlist.index(matches[0])
self._play_current_track()
return True
else:
return False
def _play_pos(self, songpos):
self._current_song_pos = songpos
self._play_current_track()
return True
def _previous(self):
self._current_song_pos -= 1
self._play_current_track()
self.backend.spotify.session.load(
Link.from_string(track.uri).as_track())
self.backend.spotify.session.play(1)
return True
def _resume(self):
@ -123,62 +100,142 @@ class LibspotifyBackend(BaseBackend):
return False
def _stop(self):
self.spotify.session.play(0)
self.backend.spotify.session.play(0)
return True
# Status querying
def status_bitrate(self):
return 320
class LibspotifyStoredPlaylistsController(BaseStoredPlaylistsController):
pass
def url_handlers(self):
return [u'spotify:', u'http://open.spotify.com/']
class LibspotifyTranslator(object):
@classmethod
def to_mopidy_id(cls, spotify_uri):
return spotify_uri_to_int(spotify_uri)
@classmethod
def to_mopidy_artist(cls, spotify_artist):
if not spotify_artist.is_loaded():
return Artist(name=u'[loading...]')
return Artist(
uri=str(Link.from_artist(spotify_artist)),
name=spotify_artist.name().decode(ENCODING),
)
@classmethod
def to_mopidy_album(cls, spotify_album):
if not spotify_album.is_loaded():
return Album(name=u'[loading...]')
# TODO pyspotify got much more data on albums than this
return Album(name=spotify_album.name().decode(ENCODING))
@classmethod
def to_mopidy_track(cls, spotify_track):
if not spotify_track.is_loaded():
return Track(name=u'[loading...]')
uri = str(Link.from_track(spotify_track, 0))
return Track(
uri=uri,
name=spotify_track.name().decode(ENCODING),
artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists()],
album=cls.to_mopidy_album(spotify_track.album()),
track_no=spotify_track.index(),
date=dt.date(spotify_track.album().year(), 1, 1),
length=spotify_track.duration(),
bitrate=320,
id=cls.to_mopidy_id(uri),
)
@classmethod
def to_mopidy_playlist(cls, spotify_playlist):
if not spotify_playlist.is_loaded():
return Playlist(name=u'[loading...]')
return Playlist(
uri=str(Link.from_playlist(spotify_playlist)),
name=spotify_playlist.name().decode(ENCODING),
tracks=[cls.to_mopidy_track(t) for t in spotify_playlist],
)
class LibspotifySessionManager(SpotifySessionManager, threading.Thread):
def __init__(self, username, password, backend):
cache_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE)
settings_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE)
appkey_file = os.path.expanduser(settings.SPOTIFY_LIB_APPKEY)
user_agent = 'Mopidy %s' % get_version()
def __init__(self, username, password, core_queue):
SpotifySessionManager.__init__(self, username, password)
threading.Thread.__init__(self)
self.backend = backend
self.core_queue = core_queue
self.connected = threading.Event()
self.audio = AlsaController()
def run(self):
self.connect()
def logged_in(self, session, error):
"""Callback used by pyspotify"""
logger.info('Logged in')
self.session = session
try:
self.playlists = session.playlist_container()
logger.debug('Got playlist container')
except Exception, e:
logger.exception(e)
self.connected.set()
def logged_out(self, session):
"""Callback used by pyspotify"""
logger.info('Logged out')
def metadata_updated(self, session):
logger.debug('Metadata updated')
self.backend.update_stored_playlists()
"""Callback used by pyspotify"""
logger.debug('Metadata updated, refreshing stored playlists')
playlists = []
for spotify_playlist in session.playlist_container():
playlists.append(
LibspotifyTranslator.to_mopidy_playlist(spotify_playlist))
self.core_queue.put({
'command': 'set_stored_playlists',
'playlists': playlists,
})
def connection_error(self, session, error):
"""Callback used by pyspotify"""
logger.error('Connection error: %s', error)
def message_to_user(self, session, message):
"""Callback used by pyspotify"""
logger.info(message)
def notify_main_thread(self, session):
"""Callback used by pyspotify"""
logger.debug('Notify main thread')
def music_delivery(self, *args, **kwargs):
"""Callback used by pyspotify"""
self.audio.music_delivery(*args, **kwargs)
def play_token_lost(self, session):
"""Callback used by pyspotify"""
logger.debug('Play token lost')
self.core_queue.put({'command': 'stop_playback'})
def log_message(self, session, data):
"""Callback used by pyspotify"""
logger.debug(data)
def end_of_track(self, session):
"""Callback used by pyspotify"""
logger.debug('End of track')
self.core_queue.put({'command': 'end_of_track'})
def search(self, query, connection):
"""Search method used by Mopidy backend"""
self.connected.wait()
def callback(results, userdata):
logger.debug(u'In search callback, translating search results')
logger.debug(results.tracks())
# TODO Include results from results.albums(), etc. too
playlist = Playlist(tracks=[
LibspotifyTranslator.to_mopidy_track(t)
for t in results.tracks()])
logger.debug(u'In search callback, sending search results')
logger.debug(['%s' % t.name for t in playlist.tracks])
connection.send(playlist)
self.session.search(query, callback)

View File

@ -1,9 +0,0 @@
class ConfigError(Exception):
pass
class MpdAckError(Exception):
pass
class MpdNotImplemented(MpdAckError):
def __init__(self, *args):
super(MpdNotImplemented, self).__init__(u'Not implemented', *args)

35
mopidy/mixers/__init__.py Normal file
View File

@ -0,0 +1,35 @@
class BaseMixer(object):
@property
def volume(self):
"""
The audio volume
Integer in range [0, 100]. :class:`None` if unknown. Values below 0 is
equal to 0. Values above 100 is equal to 100.
"""
return self._get_volume()
@volume.setter
def volume(self, volume):
volume = int(volume)
if volume < 0:
volume = 0
elif volume > 100:
volume = 100
self._set_volume(volume)
def _get_volume(self):
"""
Return volume as integer in range [0, 100]. :class:`None` if unknown.
*Must be implemented by subclass.*
"""
raise NotImplementedError
def _set_volume(self, volume):
"""
Set volume as integer in range [0, 100].
*Must be implemented by subclass.*
"""
raise NotImplementedError

18
mopidy/mixers/alsa.py Normal file
View File

@ -0,0 +1,18 @@
import alsaaudio
from mopidy.mixers import BaseMixer
class AlsaMixer(BaseMixer):
"""
Mixer which uses the Advanced Linux Sound Architecture (ALSA) to control
volume.
"""
def __init__(self):
self._mixer = alsaaudio.Mixer()
def _get_volume(self):
return self._mixer.getvolume()[0]
def _set_volume(self, volume):
self._mixer.setvolume(volume)

62
mopidy/mixers/denon.py Normal file
View File

@ -0,0 +1,62 @@
import logging
from threading import Lock
from serial import Serial
from mopidy.mixers import BaseMixer
from mopidy.settings import MIXER_EXT_PORT
logger = logging.getLogger(u'mopidy.mixers.denon')
class DenonMixer(BaseMixer):
"""
Mixer for controlling Denon amplifiers and receivers using the RS-232
protocol.
The external mixer is the authoritative source for the current volume.
This allows the user to use his remote control the volume without Mopidy
cancelling the volume setting.
**Dependencies**
- pyserial (python-serial on Debian/Ubuntu)
**Settings**
- :attr:`mopidy.settings.MIXER_EXT_PORT` -- Example: ``/dev/ttyUSB0``
"""
def __init__(self):
"""
Connects using the serial specifications from Denon's RS-232 Protocol
specification: 9600bps 8N1.
"""
self._device = Serial(port=MIXER_EXT_PORT, timeout=0.2)
self._levels = ['99'] + ["%(#)02d" % {'#': v} for v in range(0, 99)]
self._volume = 0
self._lock = Lock()
def _get_volume(self):
self._lock.acquire();
self.ensure_open_device()
self._device.write('MV?\r')
vol = str(self._device.readline()[2:4])
self._lock.release()
logger.debug(u'_get_volume() = %s' % vol)
return self._levels.index(vol)
def _set_volume(self, volume):
# Clamp according to Denon-spec
if volume > 99:
volume = 99
self._lock.acquire()
self.ensure_open_device()
self._device.write('MV%s\r'% self._levels[volume])
vol = self._device.readline()[2:4]
self._lock.release()
self._volume = self._levels.index(vol)
def ensure_open_device(self):
if not self._device.isOpen():
logger.debug(u'(re)connecting to Denon device')
self._device.open()

13
mopidy/mixers/dummy.py Normal file
View File

@ -0,0 +1,13 @@
from mopidy.mixers import BaseMixer
class DummyMixer(BaseMixer):
"""Mixer which just stores and reports the chosen volume."""
def __init__(self):
self._volume = None
def _get_volume(self):
return self._volume
def _set_volume(self, volume):
self._volume = volume

200
mopidy/mixers/nad.py Normal file
View File

@ -0,0 +1,200 @@
import logging
from serial import Serial
from multiprocessing import Pipe
from mopidy.mixers import BaseMixer
from mopidy.process import BaseProcess
from mopidy.settings import (MIXER_EXT_PORT, MIXER_EXT_SOURCE,
MIXER_EXT_SPEAKERS_A, MIXER_EXT_SPEAKERS_B)
logger = logging.getLogger('mopidy.mixers.nad')
class NadMixer(BaseMixer):
"""
Mixer for controlling NAD amplifiers and receivers using the NAD RS-232
protocol.
The NAD mixer was created using a NAD C 355BEE amplifier, but should also
work with other NAD amplifiers supporting the same RS-232 protocol (v2.x).
The C 355BEE does not give you access to the current volume. It only
supports increasing or decreasing the volume one step at the time. Other
NAD amplifiers may support more advanced volume adjustment than what is
currently used by this mixer.
Sadly, this means that if you use the remote control to change the volume
on the amplifier, Mopidy will no longer report the correct volume. To
recalibrate the mixer, set the volume to 0 through Mopidy. This will reset
the amplifier to a known state, including powering on the device, selecting
the configured speakers and input sources.
**Dependencies**
- pyserial (python-serial on Debian/Ubuntu)
**Settings**
- :attr:`mopidy.settings.MIXER_EXT_PORT` -- Example: ``/dev/ttyUSB0``
- :attr:`mopidy.settings.MIXER_EXT_SOURCE` -- Example: ``Aux``
- :attr:`mopidy.settings.MIXER_EXT_SPEAKERS_A` -- Example: ``On``
- :attr:`mopidy.settings.MIXER_EXT_SPEAKERS_B` -- Example: ``Off``
"""
def __init__(self):
self._volume = None
self._pipe, other_end = Pipe()
NadTalker(pipe=other_end).start()
def _get_volume(self):
return self._volume
def _set_volume(self, volume):
self._volume = volume
if volume == 0:
self._pipe.send({'command': 'reset_device'})
self._pipe.send({'command': 'set_volume', 'volume': volume})
class NadTalker(BaseProcess):
"""
Independent process which does the communication with the NAD device.
Since the communication is done in an independent process, Mopidy won't
block other requests while doing rather time consuming work like
calibrating the NAD device's volume.
"""
# Timeout in seconds used for read/write operations.
# If you set the timeout too low, the reads will never get complete
# confirmations and calibration will decrease volume forever. If you set
# the timeout too high, stuff takes more time. 0.2s seems like a good value
# for NAD C 355BEE.
TIMEOUT = 0.2
# Number of volume levels the device supports. 40 for NAD C 355BEE.
VOLUME_LEVELS = 40
# Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration.
_nad_volume = None
def __init__(self, pipe=None):
super(NadTalker, self).__init__()
self.pipe = pipe
def _run(self):
self._open_connection()
self._set_device_to_known_state()
while self.pipe.poll(None):
message = self.pipe.recv()
if message['command'] == 'set_volume':
self._set_volume(message['volume'])
elif message['command'] == 'reset_device':
self._set_device_to_known_state()
def _open_connection(self):
# Opens serial connection to the device.
# Communication settings: 115200 bps 8N1
logger.info(u'Connecting to serial device "%s"', MIXER_EXT_PORT)
self._device = Serial(port=MIXER_EXT_PORT, baudrate=115200,
timeout=self.TIMEOUT)
self._get_device_model()
def _set_device_to_known_state(self):
self._power_device_on()
self._select_speakers()
self._select_input_source()
self._unmute()
self._calibrate_volume()
def _get_device_model(self):
model = self._ask_device('Main.Model')
logger.info(u'Connected to device of model "%s"', model)
return model
def _power_device_on(self):
while self._ask_device('Main.Power') != 'On':
logger.info(u'Powering device on')
self._command_device('Main.Power', 'On')
def _select_speakers(self):
if MIXER_EXT_SPEAKERS_A is not None:
while self._ask_device('Main.SpeakerA') != MIXER_EXT_SPEAKERS_A:
logger.info(u'Setting speakers A "%s"', MIXER_EXT_SPEAKERS_A)
self._command_device('Main.SpeakerA', MIXER_EXT_SPEAKERS_A)
if MIXER_EXT_SPEAKERS_B is not None:
while self._ask_device('Main.SpeakerB') != MIXER_EXT_SPEAKERS_B:
logger.info(u'Setting speakers B "%s"', MIXER_EXT_SPEAKERS_B)
self._command_device('Main.SpeakerB', MIXER_EXT_SPEAKERS_B)
def _select_input_source(self):
if MIXER_EXT_SOURCE is not None:
while self._ask_device('Main.Source') != MIXER_EXT_SOURCE:
logger.info(u'Selecting input source "%s"', MIXER_EXT_SOURCE)
self._command_device('Main.Source', MIXER_EXT_SOURCE)
def _unmute(self):
while self._ask_device('Main.Mute') != 'Off':
logger.info(u'Unmuting device')
self._command_device('Main.Mute', 'Off')
def _ask_device(self, key):
self._write('%s?' % key)
return self._readline().replace('%s=' % key, '')
def _command_device(self, key, value):
self._write('%s=%s' % (key, value))
self._readline()
def _calibrate_volume(self):
# The NAD C 355BEE amplifier has 40 different volume levels. We have no
# way of asking on which level we are. Thus, we must calibrate the
# mixer by decreasing the volume 39 times.
logger.info(u'Calibrating NAD amplifier')
steps_left = self.VOLUME_LEVELS - 1
while steps_left:
if self._decrease_volume():
steps_left -= 1
self._nad_volume = 0
logger.info(u'Done calibrating NAD amplifier')
def _set_volume(self, volume):
# Increase or decrease the amplifier volume until it matches the given
# target volume.
logger.debug(u'Setting volume to %d' % volume)
target_nad_volume = int(round(volume * self.VOLUME_LEVELS / 100.0))
if self._nad_volume is None:
return # Calibration needed
while target_nad_volume > self._nad_volume:
if self._increase_volume():
self._nad_volume += 1
while target_nad_volume < self._nad_volume:
if self._decrease_volume():
self._nad_volume -= 1
def _increase_volume(self):
# Increase volume. Returns :class:`True` if confirmed by device.
self._write('Main.Volume+')
return self._readline() == 'Main.Volume+'
def _decrease_volume(self):
# Decrease volume. Returns :class:`True` if confirmed by device.
self._write('Main.Volume-')
return self._readline() == 'Main.Volume-'
def _write(self, data):
# Write data to device. Prepends and appends a newline to the data, as
# recommended by the NAD documentation.
if not self._device.isOpen():
self._device.open()
self._device.write('\n%s\n' % data)
logger.debug('Write: %s', data)
def _readline(self):
# Read line from device. The result is stripped for leading and
# trailing whitespace.
if not self._device.isOpen():
self._device.open()
result = self._device.readline(eol='\n').strip()
if result:
logger.debug('Read: %s', result)
return result

34
mopidy/mixers/osa.py Normal file
View File

@ -0,0 +1,34 @@
from subprocess import Popen, PIPE
import time
from mopidy.mixers import BaseMixer
class OsaMixer(BaseMixer):
"""Mixer which uses ``osascript`` on OS X to control volume."""
CACHE_TTL = 30
_cache = None
_last_update = None
def _valid_cache(self):
return (self._cache is not None
and self._last_update is not None
and (int(time.time() - self._last_update) < self.CACHE_TTL))
def _get_volume(self):
if not self._valid_cache():
try:
self._cache = int(Popen(
['osascript', '-e',
'output volume of (get volume settings)'],
stdout=PIPE).communicate()[0])
except ValueError:
self._cache = None
self._last_update = int(time.time())
return self._cache
def _set_volume(self, volume):
Popen(['osascript', '-e', 'set volume output volume %d' % volume])
self._cache = volume
self._last_update = int(time.time())

View File

@ -1,6 +1,24 @@
from copy import copy
class Artist(object):
class ImmutableObject(object):
"""
Superclass for immutable objects whose fields can only be modified via the
constructor.
:param kwargs: kwargs to set as fields on the object
:type kwargs: any
"""
def __init__(self, *args, **kwargs):
self.__dict__.update(kwargs)
def __setattr__(self, name, value):
if name.startswith('_'):
return super(ImmutableObject, self).__setattr__(name, value)
raise AttributeError('Object is immutable.')
class Artist(ImmutableObject):
"""
:param uri: artist URI
:type uri: string
@ -8,22 +26,14 @@ class Artist(object):
:type name: string
"""
def __init__(self, uri=None, name=None):
self._uri = None
self._name = name
#: The artist URI. Read-only.
uri = None
@property
def uri(self):
"""The artist URI. Read-only."""
return self._uri
@property
def name(self):
"""The artist name. Read-only."""
return self._name
#: The artist name. Read-only.
name = None
class Album(object):
class Album(ImmutableObject):
"""
:param uri: album URI
:type uri: string
@ -35,39 +45,31 @@ class Album(object):
:type num_tracks: integer
"""
def __init__(self, uri=None, name=None, artists=None, num_tracks=0):
self._uri = uri
self._name = name
self._artists = artists or []
self._num_tracks = num_tracks
#: The album URI. Read-only.
uri = None
@property
def uri(self):
"""The album URI. Read-only."""
return self._uri
#: The album name. Read-only.
name = None
@property
def name(self):
"""The album name. Read-only."""
return self._name
#: The number of tracks in the album. Read-only.
num_tracks = 0
def __init__(self, *args, **kwargs):
self._artists = kwargs.pop('artists', [])
super(Album, self).__init__(*args, **kwargs)
@property
def artists(self):
"""List of :class:`Artist` elements. Read-only."""
return copy(self._artists)
@property
def num_tracks(self):
"""The number of tracks in the album. Read-only."""
return self._num_tracks
class Track(object):
class Track(ImmutableObject):
"""
:param uri: track URI
:type uri: string
:param title: track title
:type title: string
:param name: track name
:type name: string
:param artists: track artists
:type artists: list of :class:`Artist`
:param album: track album
@ -84,64 +86,40 @@ class Track(object):
:type id: integer
"""
def __init__(self, uri=None, title=None, artists=None, album=None,
track_no=0, date=None, length=None, bitrate=None, id=None):
self._uri = uri
self._title = title
self._artists = artists or []
self._album = album
self._track_no = track_no
self._date = date
self._length = length
self._bitrate = bitrate
self._id = id
#: The track URI. Read-only.
uri = None
@property
def uri(self):
"""The track URI. Read-only."""
return self._uri
#: The track name. Read-only.
name = None
@property
def title(self):
"""The track title. Read-only."""
return self._title
#: The track :class:`Album`. Read-only.
album = None
#: The track number in album. Read-only.
track_no = 0
#: The track release date. Read-only.
date = None
#: The track length in milliseconds. Read-only.
length = None
#: The track's bitrate in kbit/s. Read-only.
bitrate = None
#: The track ID. Read-only.
id = None
def __init__(self, *args, **kwargs):
self._artists = kwargs.pop('artists', [])
super(Track, self).__init__(*args, **kwargs)
@property
def artists(self):
"""List of :class:`Artist`. Read-only."""
return copy(self._artists)
@property
def album(self):
"""The track :class:`Album`. Read-only."""
return self._album
@property
def track_no(self):
"""The track number in album. Read-only."""
return self._track_no
@property
def date(self):
"""The track release date. Read-only."""
return self._date
@property
def length(self):
"""The track length in milliseconds. Read-only."""
return self._length
@property
def bitrate(self):
"""The track's bitrate in kbit/s. Read-only."""
return self._bitrate
@property
def id(self):
"""The track ID. Read-only."""
return self._id
def mpd_format(self, position=0):
def mpd_format(self, position=0, search_result=False):
"""
Format track for output to MPD client.
@ -149,17 +127,23 @@ class Track(object):
:type position: integer
:rtype: list of two-tuples
"""
return [
('file', self.uri),
('Time', self.length // 1000),
result = [
('file', self.uri or ''),
('Time', self.length and (self.length // 1000) or 0),
('Artist', self.mpd_format_artists()),
('Title', self.title),
('Album', self.album.name),
('Track', '%d/%d' % (self.track_no, self.album.num_tracks)),
('Date', self.date),
('Pos', position),
('Id', self.id),
('Title', self.name or ''),
('Album', self.album and self.album.name or ''),
('Date', self.date or ''),
]
if self.album is not None and self.album.num_tracks != 0:
result.append(('Track', '%d/%d' % (
self.track_no, self.album.num_tracks)))
else:
result.append(('Track', self.track_no))
if not search_result:
result.append(('Pos', position))
result.append(('Id', self.id or position))
return result
def mpd_format_artists(self):
"""
@ -170,7 +154,7 @@ class Track(object):
return u', '.join([a.name for a in self.artists])
class Playlist(object):
class Playlist(ImmutableObject):
"""
:param uri: playlist URI
:type uri: string
@ -180,20 +164,20 @@ class Playlist(object):
:type tracks: list of :class:`Track` elements
"""
def __init__(self, uri=None, name=None, tracks=None):
self._uri = uri
self._name = name
self._tracks = tracks or []
#: The playlist URI. Read-only.
uri = None
@property
def uri(self):
"""The playlist URI. Read-only."""
return self._uri
#: The playlist name. Read-only.
name = None
@property
def name(self):
"""The playlist name. Read-only."""
return self._name
#: The playlist modification time. Read-only.
#:
#: :class:`datetime.datetime`, or :class:`None` if unknown.
last_modified = None
def __init__(self, *args, **kwargs):
self._tracks = kwargs.pop('tracks', [])
super(Playlist, self).__init__(*args, **kwargs)
@property
def tracks(self):
@ -205,15 +189,56 @@ class Playlist(object):
"""The number of tracks in the playlist. Read-only."""
return len(self._tracks)
def mpd_format(self, start=0, end=None):
def mpd_format(self, start=0, end=None, search_result=False):
"""
Format playlist for output to MPD client.
Optionally limit output to the slice ``[start:end]`` of the playlist.
:param start: position of first track to include in output
:type start: int (positive or negative)
:param end: position after last track to include in output
:type end: int (positive or negative) or :class:`None` for end of list
:rtype: list of lists of two-tuples
"""
if end is None:
end = self.length
if start < 0:
range_start = self.length + start
else:
range_start = start
if end is not None and end < 0:
range_end = self.length - end
elif end is not None and end >= 0:
range_end = end
else:
range_end = self.length
tracks = []
for track, position in zip(self.tracks, range(start, end)):
tracks.append(track.mpd_format(position))
for track, position in zip(self.tracks[start:end],
range(range_start, range_end)):
tracks.append(track.mpd_format(position, search_result))
return tracks
def with_(self, uri=None, name=None, tracks=None, last_modified=None):
"""
Create a new playlist object with the given values. The values that are
not given are taken from the object the method is called on.
Does not change the object on which it is called.
:param uri: playlist URI
:type uri: string
:param name: playlist name
:type name: string
:param tracks: playlist's tracks
:type tracks: list of :class:`Track` elements
:rtype: :class:`Playlist`
"""
if uri is None:
uri = self.uri
if name is None:
name = self.name
if tracks is None:
tracks = self.tracks
if last_modified is None:
last_modified = self.last_modified
return Playlist(uri=uri, name=name, tracks=tracks,
last_modified=last_modified)

View File

@ -0,0 +1,8 @@
from mopidy import MopidyException
class MpdAckError(MopidyException):
pass
class MpdNotImplemented(MpdAckError):
def __init__(self):
super(MpdNotImplemented, self).__init__(u'Not implemented')

1493
mopidy/mpd/frontend.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,421 +0,0 @@
import logging
import re
import sys
from mopidy import settings
from mopidy.exceptions import MpdAckError, MpdNotImplemented
logger = logging.getLogger('mpd.handler')
_request_handlers = {}
def register(pattern):
def decorator(func):
if pattern in _request_handlers:
raise ValueError(u'Tried to redefine handler for %s with %s' % (
pattern, func))
_request_handlers[pattern] = func
return func
return decorator
def flatten(the_list):
result = []
for element in the_list:
if isinstance(element, list):
result.extend(flatten(element))
else:
result.append(element)
return result
class MpdHandler(object):
def __init__(self, session=None, backend=None):
self.session = session
self.backend = backend
self.command_list = False
def handle_request(self, request, add_ok=True):
if self.command_list is not False and request != u'command_list_end':
self.command_list.append(request)
return None
for pattern in _request_handlers:
matches = re.match(pattern, request)
if matches is not None:
groups = matches.groupdict()
try:
result = _request_handlers[pattern](self, **groups)
except MpdAckError, e:
return self.handle_response(u'ACK %s' % e, add_ok=False)
if self.command_list is not False:
return None
else:
return self.handle_response(result, add_ok)
raise MpdAckError(u'Unknown command: %s' % request)
def handle_response(self, result, add_ok=True):
response = []
if result is None:
result = []
elif not isinstance(result, list):
result = [result]
for line in flatten(result):
if isinstance(line, dict):
for (key, value) in line.items():
response.append(u'%s: %s' % (key, value))
elif isinstance(line, tuple):
(key, value) = line
response.append(u'%s: %s' % (key, value))
else:
response.append(line)
if add_ok:
response.append(u'OK')
return response
@register(r'^add "(?P<uri>[^"]*)"$')
def _add(self, uri):
self.backend.playlist_add_track(uri)
@register(r'^addid "(?P<uri>[^"]*)"( (?P<songpos>\d+))*$')
def _add(self, uri, songpos=None):
self.backend.playlist_add_track(uri, int(songpos))
@register(r'^clear$')
def _clear(self):
raise MpdNotImplemented # TODO
@register(r'^clearerror$')
def _clearerror(self):
raise MpdNotImplemented # TODO
@register(r'^close$')
def _close(self):
self.session.do_close()
@register(r'^command_list_begin$')
def _command_list_begin(self):
self.command_list = []
self.command_list_ok = False
@register(r'^command_list_ok_begin$')
def _command_list_ok_begin(self):
self.command_list = []
self.command_list_ok = True
@register(r'^command_list_end$')
def _command_list_end(self):
(command_list, self.command_list) = (self.command_list, False)
(command_list_ok, self.command_list_ok) = (self.command_list_ok, False)
result = []
for command in command_list:
response = self.handle_request(command, add_ok=False)
if response is not None:
result.append(response)
if command_list_ok:
response.append(u'list_OK')
return result
@register(r'^consume "(?P<state>[01])"$')
def _consume(self, state):
state = int(state)
if state:
raise MpdNotImplemented # TODO
else:
raise MpdNotImplemented # TODO
@register(r'^count "(?P<tag>[^"]+)" "(?P<needle>[^"]+)"$')
def _count(self, tag, needle):
raise MpdNotImplemented # TODO
@register(r'^crossfade "(?P<seconds>\d+)"$')
def _crossfade(self, seconds):
seconds = int(seconds)
raise MpdNotImplemented # TODO
@register(r'^currentsong$')
def _currentsong(self):
return self.backend.current_song()
@register(r'^delete "(?P<songpos>\d+)"$')
@register(r'^delete "(?P<start>\d+):(?P<end>\d+)*"$')
def _delete(self, songpos=None, start=None, end=None):
raise MpdNotImplemented # TODO
@register(r'^deleteid "(?P<songid>\d+)"$')
def _deleteid(self, songid):
raise MpdNotImplemented # TODO
@register(r'^$')
def _empty(self):
pass
@register(r'^find "(?P<type>(album|artist|title))" "(?P<what>[^"]+)"$')
def _find(self, type, what):
raise MpdNotImplemented # TODO
@register(r'^findadd "(?P<type>(album|artist|title))" "(?P<what>[^"]+)"$')
def _findadd(self, type, what):
result = self._find(type, what)
# TODO Add result to current playlist
#return result
@register(r'^idle$')
@register(r'^idle (?P<subsystems>.+)$')
def _idle(self, subsystems=None):
raise MpdNotImplemented # TODO
@register(r'^kill$')
def _kill(self):
self.session.do_kill()
@register(r'^list "(?P<type>artist)"$')
@register(r'^list "(?P<type>album)"( "(?P<artist>[^"]+)")*$')
def _list(self, type, artist=None):
raise MpdNotImplemented # TODO
@register(r'^listall "(?P<uri>[^"]+)"')
def _listall(self, uri):
raise MpdNotImplemented # TODO
@register(r'^listallinfo "(?P<uri>[^"]+)"')
def _listallinfo(self, uri):
raise MpdNotImplemented # TODO
@register(r'^listplaylist "(?P<name>[^"]+)"$')
def _listplaylist(self, name):
raise MpdNotImplemented # TODO
@register(r'^listplaylistinfo "(?P<name>[^"]+)"$')
def _listplaylistinfo(self, name):
raise MpdNotImplemented # TODO
@register(r'^listplaylists$')
def _listplaylists(self):
return self.backend.playlists_list()
@register(r'^load "(?P<name>[^"]+)"$')
def _load(self, name):
return self.backend.playlist_load(name)
@register(r'^lsinfo$')
@register(r'^lsinfo "(?P<uri>[^"]*)"$')
def _lsinfo(self, uri=None):
if uri == u'/' or uri is None:
return self._listplaylists()
raise MpdNotImplemented # TODO
@register(r'^move "(?P<songpos>\d+)" "(?P<to>\d+)"$')
@register(r'^move "(?P<start>\d+):(?P<end>\d+)*" "(?P<to>\d+)"$')
def _move(self, songpos=None, start=None, end=None, to=None):
raise MpdNotImplemented # TODO
@register(r'^moveid "(?P<songid>\d+)" "(?P<to>\d+)"$')
def _moveid(self, songid, to):
raise MpdNotImplemented # TODO
@register(r'^next$')
def _next(self):
return self.backend.next()
@register(r'^password "(?P<password>[^"]+)"$')
def _password(self, password):
raise MpdNotImplemented # TODO
@register(r'^pause "(?P<state>[01])"$')
def _pause(self, state):
if int(state):
self.backend.pause()
else:
self.backend.resume()
@register(r'^ping$')
def _ping(self):
pass
@register(r'^play$')
def _play(self):
return self.backend.play()
@register(r'^play "(?P<songpos>\d+)"$')
def _playpos(self, songpos):
return self.backend.play(songpos=int(songpos))
@register(r'^playid "(?P<songid>\d+)"$')
def _playid(self, songid):
return self.backend.play(songid=int(songid))
@register(r'^playlist$')
def _playlist(self):
return self._playlistinfo()
@register(r'^playlistadd "(?P<name>[^"]+)" "(?P<uri>[^"]+)"$')
def _playlistadd(self, name, uri):
raise MpdNotImplemented # TODO
@register(r'^playlistclear "(?P<name>[^"]+)"$')
def _playlistclear(self, name):
raise MpdNotImplemented # TODO
@register(r'^playlistdelete "(?P<name>[^"]+)" "(?P<songpos>\d+)"$')
def _playlistdelete(self, name, songpos):
raise MpdNotImplemented # TODO
@register(r'^playlistfind "(?P<tag>[^"]+)" "(?P<needle>[^"]+)"$')
def _playlistfind(self, tag, needle):
raise MpdNotImplemented # TODO
@register(r'^playlistid( "(?P<songid>\S+)")*$')
def _playlistid(self, songid=None):
return self.backend.playlist_info(songid, None, None)
@register(r'^playlistinfo$')
@register(r'^playlistinfo "(?P<songpos>\d+)"$')
@register(r'^playlistinfo "(?P<start>\d+):(?P<end>\d+)*"$')
def _playlistinfo(self, songpos=None, start=None, end=None):
return self.backend.playlist_info(songpos, start, end)
@register(r'^playlistmove "(?P<name>[^"]+)" "(?P<songid>\d+)" "(?P<songpos>\d+)"$')
def _playlistdelete(self, name, songid, songpos):
raise MpdNotImplemented # TODO
@register(r'^playlistsearch "(?P<tag>[^"]+)" "(?P<needle>[^"]+)"$')
def _playlistsearch(self, tag, needle):
raise MpdNotImplemented # TODO
@register(r'^plchanges "(?P<version>\d+)"$')
def _plchanges(self, version):
return self.backend.playlist_changes_since(version)
@register(r'^plchangesposid "(?P<version>\d+)"$')
def _plchangesposid(self, version):
raise MpdNotImplemented # TODO
@register(r'^previous$')
def _previous(self):
return self.backend.previous()
@register(r'^rename "(?P<old_name>[^"]+)" "(?P<new_name>[^"]+)"$')
def _rename(self, old_name, new_name):
raise MpdNotImplemented # TODO
@register(r'^random "(?P<state>[01])"$')
def _random(self, state):
state = int(state)
if state:
raise MpdNotImplemented # TODO
else:
raise MpdNotImplemented # TODO
@register(r'^repeat "(?P<state>[01])"$')
def _repeat(self, state):
state = int(state)
if state:
raise MpdNotImplemented # TODO
else:
raise MpdNotImplemented # TODO
@register(r'^replay_gain_mode "(?P<mode>(off|track|album))"$')
def _replay_gain_mode(self, mode):
raise MpdNotImplemented # TODO
@register(r'^replay_gain_status$')
def _replay_gain_status(self):
return u'off' # TODO
@register(r'^rescan( "(?P<uri>[^"]+)")*$')
def _update(self, uri=None):
return self._update(uri, rescan_unmodified_files=True)
@register(r'^rm "(?P<name>[^"]+)"$')
def _rm(self, name):
raise MpdNotImplemented # TODO
@register(r'^save "(?P<name>[^"]+)"$')
def _save(self, name):
raise MpdNotImplemented # TODO
@register(r'^search "(?P<type>(album|artist|filename|title))" "(?P<what>[^"]+)"$')
def _search(self, type, what):
return self.backend.search(type, what)
@register(r'^seek "(?P<songpos>\d+)" "(?P<seconds>\d+)"$')
def _seek(self, songpos, seconds):
raise MpdNotImplemented # TODO
@register(r'^seekid "(?P<songid>\d+)" "(?P<seconds>\d+)"$')
def _seekid(self, songid, seconds):
raise MpdNotImplemented # TODO
@register(r'^setvol "(?P<volume>-*\d+)"$')
def _setvol(self, volume):
volume = int(volume)
if volume < 0:
volume = 0
if volume > 100:
volume = 100
raise MpdNotImplemented # TODO
@register(r'^shuffle$')
@register(r'^shuffle "(?P<start>\d+):(?P<end>\d+)*"$')
def _shuffle(self, start=None, end=None):
raise MpdNotImplemented # TODO
@register(r'^single "(?P<state>[01])"$')
def _single(self, state):
state = int(state)
if state:
raise MpdNotImplemented # TODO
else:
raise MpdNotImplemented # TODO
@register(r'^stats$')
def _stats(self):
pass # TODO
return {
'artists': 0,
'albums': 0,
'songs': 0,
'uptime': self.session.stats_uptime(),
'db_playtime': 0,
'db_update': 0,
'playtime': 0,
}
@register(r'^stop$')
def _stop(self):
self.backend.stop()
@register(r'^status$')
def _status(self):
result = [
('volume', self.backend.status_volume()),
('repeat', self.backend.status_repeat()),
('random', self.backend.status_random()),
('single', self.backend.status_single()),
('consume', self.backend.status_consume()),
('playlist', self.backend.status_playlist()),
('playlistlength', self.backend.status_playlist_length()),
('xfade', self.backend.status_xfade()),
('state', self.backend.status_state()),
]
if self.backend.status_playlist_length() > 0:
result.append(('song', self.backend.status_song_id()))
result.append(('songid', self.backend.status_song_id()))
if self.backend.state in (self.backend.PLAY, self.backend.PAUSE):
result.append(('time', self.backend.status_time()))
result.append(('bitrate', self.backend.status_bitrate()))
return result
@register(r'^swap "(?P<songpos1>\d+)" "(?P<songpos2>\d+)"$')
def _swap(self, songpos1, songpos2):
raise MpdNotImplemented # TODO
@register(r'^swapid "(?P<songid1>\d+)" "(?P<songid2>\d+)"$')
def _swapid(self, songid1, songid2):
raise MpdNotImplemented # TODO
@register(r'^update( "(?P<uri>[^"]+)")*$')
def _update(self, uri=None, rescan_unmodified_files=False):
return {'updating_db': 0} # TODO
@register(r'^urlhandlers$')
def _urlhandlers(self):
return self.backend.url_handlers()

View File

@ -1,41 +1,93 @@
"""
This is our MPD server implementation.
"""
import asynchat
import asyncore
import logging
import multiprocessing
import socket
import sys
import time
from mopidy import config
from mopidy.mpd.session import MpdSession
from mopidy import get_mpd_protocol_version, settings
from mopidy.utils import indent, pickle_connection
logger = logging.getLogger(u'mpd.server')
logger = logging.getLogger('mopidy.mpd.server')
#: The MPD protocol uses UTF-8 for encoding all data.
ENCODING = u'utf-8'
#: The MPD protocol uses ``\n`` as line terminator.
LINE_TERMINATOR = u'\n'
class MpdServer(asyncore.dispatcher):
def __init__(self, session_class=MpdSession, backend=None):
"""
The MPD server. Creates a :class:`MpdSession` for each client connection.
"""
def __init__(self, core_queue):
asyncore.dispatcher.__init__(self)
self.session_class = session_class
self.backend = backend
self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
self.set_reuse_addr()
self.bind((config.MPD_SERVER_HOSTNAME, config.MPD_SERVER_PORT))
self.listen(1)
self.started_at = int(time.time())
logger.info(u'Please connect to %s port %s using an MPD client.',
config.MPD_SERVER_HOSTNAME, config.MPD_SERVER_PORT)
try:
self.core_queue = core_queue
self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
self.set_reuse_addr()
self.bind((settings.SERVER_HOSTNAME, settings.SERVER_PORT))
self.listen(1)
logger.info(u'MPD server running at [%s]:%s',
settings.SERVER_HOSTNAME, settings.SERVER_PORT)
except IOError, e:
sys.exit('MPD server startup failed: %s' % e)
def handle_accept(self):
(client_socket, client_address) = self.accept()
logger.info(u'Connection from: [%s]:%s', *client_address)
self.session_class(self, client_socket, client_address,
backend=self.backend)
logger.info(u'MPD client connection from [%s]:%s', *client_address)
MpdSession(self, client_socket, client_address, self.core_queue)
def handle_close(self):
self.close()
def do_kill(self):
logger.info(u'Received "kill". Shutting down.')
self.handle_close()
sys.exit(0)
@property
def uptime(self):
return int(time.time()) - self.started_at
class MpdSession(asynchat.async_chat):
"""
The MPD client session. Dispatches MPD requests to the frontend.
"""
def __init__(self, server, client_socket, client_address, core_queue):
asynchat.async_chat.__init__(self, sock=client_socket)
self.server = server
self.client_address = client_address
self.core_queue = core_queue
self.input_buffer = []
self.set_terminator(LINE_TERMINATOR.encode(ENCODING))
self.send_response(u'OK MPD %s' % get_mpd_protocol_version())
def collect_incoming_data(self, data):
self.input_buffer.append(data)
def found_terminator(self):
data = ''.join(self.input_buffer).strip()
self.input_buffer = []
input = data.decode(ENCODING)
logger.debug(u'Input: %s', indent(input))
self.handle_request(input)
def handle_request(self, input):
my_end, other_end = multiprocessing.Pipe()
self.core_queue.put({
'command': 'mpd_request',
'request': input,
'reply_to': pickle_connection(other_end),
})
my_end.poll(None)
response = my_end.recv()
if response is not None:
self.handle_response(response)
def handle_response(self, response):
self.send_response(LINE_TERMINATOR.join(response))
def send_response(self, output):
logger.debug(u'Output: %s', indent(output))
output = u'%s%s' % (output, LINE_TERMINATOR)
data = output.encode(ENCODING)
self.push(data)

View File

@ -1,66 +0,0 @@
import asynchat
import logging
from mopidy import get_mpd_protocol_version, config
from mopidy.exceptions import MpdAckError
from mopidy.mpd.handler import MpdHandler
logger = logging.getLogger(u'mpd.session')
def indent(string, places=4, linebreak=config.MPD_LINE_TERMINATOR):
lines = string.split(linebreak)
if len(lines) == 1:
return string
result = u''
for line in lines:
result += linebreak + ' ' * places + line
return result
class MpdSession(asynchat.async_chat):
def __init__(self, server, client_socket, client_address, backend,
handler_class=MpdHandler):
asynchat.async_chat.__init__(self, sock=client_socket)
self.server = server
self.client_address = client_address
self.input_buffer = []
self.set_terminator(config.MPD_LINE_TERMINATOR.encode(
config.MPD_LINE_ENCODING))
self.handler = handler_class(session=self, backend=backend)
self.send_response(u'OK MPD %s' % get_mpd_protocol_version())
def do_close(self):
logger.info(u'Closing connection with [%s]:%s', *self.client_address)
self.close_when_done()
def do_kill(self):
self.server.do_kill()
def collect_incoming_data(self, data):
self.input_buffer.append(data)
def found_terminator(self):
data = ''.join(self.input_buffer).strip()
self.input_buffer = []
input = data.decode(config.MPD_LINE_ENCODING)
logger.debug(u'Input: %s', indent(input))
self.handle_request(input)
def handle_request(self, input):
try:
response = self.handler.handle_request(input)
self.handle_response(response)
except MpdAckError, e:
logger.warning(e)
return self.send_response(u'ACK %s' % e)
def handle_response(self, response):
self.send_response(config.MPD_LINE_TERMINATOR.join(response))
def send_response(self, output):
logger.debug(u'Output: %s', indent(output))
output = u'%s%s' % (output, config.MPD_LINE_TERMINATOR)
data = output.encode(config.MPD_LINE_ENCODING)
self.push(data)
def stats_uptime(self):
return self.server.uptime

53
mopidy/process.py Normal file
View File

@ -0,0 +1,53 @@
import logging
import multiprocessing
import sys
from mopidy import settings, SettingsError
from mopidy.utils import get_class, unpickle_connection
logger = logging.getLogger('mopidy.process')
class BaseProcess(multiprocessing.Process):
def run(self):
try:
self._run()
except KeyboardInterrupt:
logger.info(u'Interrupted by user')
sys.exit(0)
except SettingsError, e:
logger.error(e)
sys.exit(1)
def _run(self):
raise NotImplementedError
class CoreProcess(BaseProcess):
def __init__(self, core_queue):
super(CoreProcess, self).__init__()
self.core_queue = core_queue
def _run(self):
self._setup()
while True:
message = self.core_queue.get()
self._process_message(message)
def _setup(self):
self._backend = get_class(settings.BACKENDS[0])(
core_queue=self.core_queue)
self._frontend = get_class(settings.FRONTEND)(backend=self._backend)
def _process_message(self, message):
if message['command'] == 'mpd_request':
response = self._frontend.handle_request(message['request'])
connection = unpickle_connection(message['reply_to'])
connection.send(response)
elif message['command'] == 'end_of_track':
self._backend.playback.end_of_track_callback()
elif message['command'] == 'stop_playback':
self._backend.playback.stop()
elif message['command'] == 'set_stored_playlists':
self._backend.stored_playlists.playlists = message['playlists']
else:
logger.warning(u'Cannot handle message: %s', message)

View File

@ -1,17 +1,109 @@
CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(threadName)s] %(name)s\n %(message)s'
MPD_LINE_ENCODING = u'utf-8'
MPD_LINE_TERMINATOR = u'\n'
MPD_SERVER_HOSTNAME = u'localhost'
MPD_SERVER_PORT = 6600
"""
Available settings and their default values.
BACKEND=u'mopidy.backends.despotify.DespotifyBackend'
#BACKEND=u'mopidy.backends.libspotify.LibspotifyBackend'
.. warning::
Do *not* change settings in ``mopidy/settings.py``. Instead, add a file
called ``~/.mopidy/settings.py`` and redefine settings there.
"""
from __future__ import absolute_import
import os
import sys
#: List of playback backends to use. See :mod:`mopidy.backends` for all
#: available backends. Default::
#:
#: BACKENDS = (u'mopidy.backends.despotify.DespotifyBackend',)
#:
#: .. note::
#: Currently only the first backend in the list is used.
BACKENDS = (
u'mopidy.backends.despotify.DespotifyBackend',
#u'mopidy.backends.libspotify.LibspotifyBackend',
)
#: The log format used on the console. See
#: http://docs.python.org/library/logging.html#formatter-objects for details on
#: the format.
CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] %(name)s\n %(message)s'
#: Protocol frontend to use. Default::
#:
#: FRONTEND = u'mopidy.mpd.frontend.MpdFrontend'
FRONTEND = u'mopidy.mpd.frontend.MpdFrontend'
#: Sound mixer to use. See :mod:`mopidy.mixers` for all available mixers.
#:
#: Default on Linux::
#:
#: MIXER = u'mopidy.mixers.alsa.AlsaMixer'
#:
#: Default on OS X::
#:
#: MIXER = u'mopidy.mixers.osa.OsaMixer'
#:
#: Default on other operating systems::
#:
#: MIXER = u'mopidy.mixers.dummy.DummyMixer'
MIXER = u'mopidy.mixers.dummy.DummyMixer'
if sys.platform == 'linux2':
MIXER = u'mopidy.mixers.alsa.AlsaMixer'
elif sys.platform == 'darwin':
MIXER = u'mopidy.mixers.osa.OsaMixer'
#: External mixers only. Which port the mixer is connected to.
#:
#: This must point to the device port like ``/dev/ttyUSB0``.
#: *Default:* :class:`None`
MIXER_EXT_PORT = None
#: External mixers only. What input source the external mixer should use.
#:
#: Example: ``Aux``. *Default:* :class:`None`
MIXER_EXT_SOURCE = None
#: External mixers only. What state Speakers A should be in.
#:
#: *Default:* :class:`None`.
MIXER_EXT_SPEAKERS_A = None
#: External mixers only. What state Speakers B should be in.
#:
#: *Default:* :class:`None`.
MIXER_EXT_SPEAKERS_B = None
#: Server to use. Default::
#:
#: SERVER = u'mopidy.mpd.server.MpdServer'
SERVER = u'mopidy.mpd.server.MpdServer'
#: Which address Mopidy should bind to. Examples:
#:
#: ``localhost``
#: Listens only on the loopback interface. *Default.*
#: ``0.0.0.0``
#: Listens on all interfaces.
SERVER_HOSTNAME = u'localhost'
#: Which TCP port Mopidy should listen to. *Default: 6600*
SERVER_PORT = 6600
#: Your Spotify Premium username. Used by all Spotify backends.
SPOTIFY_USERNAME = u''
#: Your Spotify Premium password. Used by all Spotify backends.
SPOTIFY_PASSWORD = u''
try:
from mopidy.local_settings import *
except ImportError:
pass
#: Path to your libspotify application key. Used by LibspotifyBackend.
SPOTIFY_LIB_APPKEY = u'~/.mopidy/spotify_appkey.key'
#: Path to the libspotify cache. Used by LibspotifyBackend.
SPOTIFY_LIB_CACHE = u'~/.mopidy/libspotify_cache'
# Import user specific settings
dotdir = os.path.expanduser(u'~/.mopidy/')
settings_file = os.path.join(dotdir, u'settings.py')
if os.path.isfile(settings_file):
sys.path.insert(0, dotdir)
from settings import *

93
mopidy/utils.py Normal file
View File

@ -0,0 +1,93 @@
import logging
from multiprocessing.reduction import reduce_connection
import os
import pickle
logger = logging.getLogger('mopidy.utils')
def flatten(the_list):
result = []
for element in the_list:
if isinstance(element, list):
result.extend(flatten(element))
else:
result.append(element)
return result
def get_class(name):
module_name = name[:name.rindex('.')]
class_name = name[name.rindex('.') + 1:]
logger.debug('Loading: %s', name)
module = __import__(module_name, globals(), locals(), [class_name], -1)
class_object = getattr(module, class_name)
return class_object
def get_or_create_dotdir(dotdir):
dotdir = os.path.expanduser(dotdir)
if not os.path.isdir(dotdir):
logger.info(u'Creating %s', dotdir)
os.mkdir(dotdir, 0755)
return dotdir
def indent(string, places=4, linebreak='\n'):
lines = string.split(linebreak)
if len(lines) == 1:
return string
result = u''
for line in lines:
result += linebreak + ' ' * places + line
return result
def pickle_connection(connection):
return pickle.dumps(reduce_connection(connection))
def unpickle_connection(pickled_connection):
# From http://stackoverflow.com/questions/1446004
(func, args) = pickle.loads(pickled_connection)
return func(*args)
def spotify_uri_to_int(uri, output_bits=31):
"""
Stable one-way translation from Spotify URI to 31-bit integer.
Spotify track URIs has 62^22 possible values, which requires 131 bits of
storage. The original MPD server uses 32-bit unsigned integers for track
IDs. GMPC seems to think the track ID is a signed integer, thus we use 31
output bits.
In other words, this function throws away 100 bits of information. Since we
only use the track IDs to identify a track within a single Mopidy instance,
this information loss is acceptable. The chances of getting two different
tracks with the same track ID loaded in the same Mopidy instance is still
rather slim. 1 to 2,147,483,648 to be exact.
Normal usage, with data loss::
>>> spotify_uri_to_int('spotify:track:5KRRcT67VNIZUygEbMoIC1')
624351954
No data loss, may be converted back into a Spotify URI::
>>> spotify_uri_to_int('spotify:track:5KRRcT67VNIZUygEbMoIC1',
... output_bits=131)
101411513484007705241035418492696638725L
:param uri: Spotify URI on the format ``spotify:track:*``
:type uri: string
:param output_bits: number of bits of information kept in the return value
:type output_bits: int
:rtype: int
"""
CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
BITS_PER_CHAR = 6 # int(math.ceil(math.log(len(CHARS), 2)))
key = uri.split(':')[-1]
full_id = 0
for i, char in enumerate(key):
full_id ^= CHARS.index(char) << BITS_PER_CHAR * i
compressed_id = 0
while full_id != 0:
compressed_id ^= (full_id & (2 ** output_bits - 1))
full_id >>= output_bits
return int(compressed_id)

2
requirements-docs.txt Normal file
View File

@ -0,0 +1,2 @@
Sphinx
pygraphviz

View File

@ -0,0 +1 @@
pyserial

2
requirements-tests.txt Normal file
View File

@ -0,0 +1,2 @@
coverage
nose

8
setup.cfg Normal file
View File

@ -0,0 +1,8 @@
[nosetests]
verbosity = 1
#with-doctest = 1
#with-coverage = 1
cover-package = mopidy
cover-inclusive = 1
cover-html = 1
with-xunit = 1

69
setup.py Normal file
View File

@ -0,0 +1,69 @@
from distutils.core import setup
from distutils.command.install import INSTALL_SCHEMES
import os
from mopidy import get_version
def fullsplit(path, result=None):
"""
Split a pathname into components (the opposite of os.path.join) in a
platform-neutral way.
"""
if result is None:
result = []
head, tail = os.path.split(path)
if head == '':
return [tail] + result
if head == path:
return result
return fullsplit(head, [tail] + result)
# Tell distutils to put the data_files in platform-specific installation
# locations. See here for an explanation:
# http://groups.google.com/group/comp.lang.python/browse_thread/thread/35ec7b2fed36eaec/2105ee4d9e8042cb
for scheme in INSTALL_SCHEMES.values():
scheme['data'] = scheme['purelib']
# Compile the list of packages available, because distutils doesn't have
# an easy way to do this.
packages, data_files = [], []
root_dir = os.path.dirname(__file__)
if root_dir != '':
os.chdir(root_dir)
project_dir = 'mopidy'
for dirpath, dirnames, filenames in os.walk(project_dir):
# Ignore dirnames that start with '.'
for i, dirname in enumerate(dirnames):
if dirname.startswith('.'):
del dirnames[i]
if '__init__.py' in filenames:
packages.append('.'.join(fullsplit(dirpath)))
elif filenames:
data_files.append([dirpath,
[os.path.join(dirpath, f) for f in filenames]])
setup(
name='Mopidy',
version=get_version(),
author='Stein Magnus Jodal',
author_email='stein.magnus@jodal.no',
packages=packages,
data_files=data_files,
scripts=['bin/mopidy'],
url='http://www.mopidy.com/',
license='GPLv2',
description='MPD server with Spotify support',
long_description=open('README.rst').read(),
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: No Input/Output (Daemon)',
'Intended Audience :: End Users/Desktop',
'License :: OSI Approved :: GNU General Public License (GPL)',
'Operating System :: MacOS :: MacOS X',
'Operating System :: POSIX :: Linux',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Topic :: Multimedia :: Sound/Audio :: Players',
],
)

View File

@ -1 +0,0 @@
-e bzr+http://liw.iki.fi/bzr/coverage-test-runner/trunk/#egg=CoverageTestRunner

View File

@ -1,16 +1,4 @@
import logging
import os
import sys
from CoverageTestRunner import CoverageTestRunner
def main():
logging.basicConfig(level=logging.CRITICAL)
sys.path.insert(0,
os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
r = CoverageTestRunner()
r.add_pair('mopidy/mpd/handler.py', 'tests/mpd/handlertest.py')
r.run()
import nose
if __name__ == '__main__':
main()
nose.main()

View File

@ -0,0 +1,46 @@
class BaseCurrentPlaylistControllerTest(object):
uris = []
backend_class = None
def setUp(self):
self.backend = self.backend_class()
def test_add(self):
playlist = self.backend.current_playlist
for uri in self.uris:
playlist.add(uri)
self.assertEqual(uri, playlist.tracks[-1].uri)
def test_add_at_position(self):
playlist = self.backend.current_playlist
for uri in self.uris:
playlist.add(uri, 0)
self.assertEqual(uri, playlist.tracks[0].uri)
# FIXME test other placements
class BasePlaybackControllerTest(object):
backend_class = None
def setUp(self):
self.backend = self.backend_class()
def test_play(self):
playback = self.backend.playback
self.assertEqual(playback.state, playback.STOPPED)
playback.play()
self.assertEqual(playback.state, playback.PLAYING)
def test_next(self):
playback = self.backend.playback
current_song = playback.playlist_position
playback.next()
self.assertEqual(playback.playlist_position, current_song+1)

View File

@ -0,0 +1,99 @@
import unittest
from mopidy.backends.dummy import DummyBackend, DummyCurrentPlaylistController
from mopidy.mixers.dummy import DummyMixer
from mopidy.models import Playlist, Track
class CurrentPlaylistGetTest(unittest.TestCase):
def setUp(self):
self.b = DummyBackend(mixer=DummyMixer())
self.c = self.b.current_playlist
def test_get_by_id_returns_unique_match(self):
track = Track(id=1)
self.c.playlist = Playlist(tracks=[Track(id=13), track, Track(id=17)])
self.assertEqual(track, self.c.get(id=1))
def test_get_by_id_raises_error_if_multiple_matches(self):
track = Track(id=1)
self.c.playlist = Playlist(tracks=[Track(id=13), track, track])
try:
self.c.get(id=1)
self.fail(u'Should raise LookupError if multiple matches')
except LookupError as e:
self.assertEqual(u'"id=1" match multiple tracks', e[0])
def test_get_by_id_raises_error_if_no_match(self):
self.c.playlist = Playlist(tracks=[Track(id=13), Track(id=17)])
try:
self.c.get(id=1)
self.fail(u'Should raise LookupError if no match')
except LookupError as e:
self.assertEqual(u'"id=1" match no tracks', e[0])
def test_get_by_uri_returns_unique_match(self):
track = Track(uri='a')
self.c.playlist = Playlist(
tracks=[Track(uri='z'), track, Track(uri='y')])
self.assertEqual(track, self.c.get(uri='a'))
def test_get_by_uri_raises_error_if_multiple_matches(self):
track = Track(uri='a')
self.c.playlist = Playlist(tracks=[Track(uri='z'), track, track])
try:
self.c.get(uri='a')
self.fail(u'Should raise LookupError if multiple matches')
except LookupError as e:
self.assertEqual(u'"uri=a" match multiple tracks', e[0])
def test_get_by_uri_raises_error_if_no_match(self):
self.c.playlist = Playlist(tracks=[Track(uri='z'), Track(uri='y')])
try:
self.c.get(uri='a')
self.fail(u'Should raise LookupError if no match')
except LookupError as e:
self.assertEqual(u'"uri=a" match no tracks', e[0])
def test_get_by_multiple_criteria_returns_elements_matching_all(self):
track1 = Track(id=1, uri='a')
track2 = Track(id=1, uri='b')
track3 = Track(id=2, uri='b')
self.c.playlist = Playlist(tracks=[track1, track2, track3])
self.assertEqual(track1, self.c.get(id=1, uri='a'))
self.assertEqual(track2, self.c.get(id=1, uri='b'))
self.assertEqual(track3, self.c.get(id=2, uri='b'))
def test_get_by_criteria_that_is_not_present_in_all_elements(self):
track1 = Track(id=1)
track2 = Track(uri='b')
track3 = Track(id=2)
self.c.playlist = Playlist(tracks=[track1, track2, track3])
self.assertEqual(track1, self.c.get(id=1))
class StoredPlaylistsGetTest(unittest.TestCase):
def setUp(self):
self.b = DummyBackend(mixer=DummyMixer())
self.s = self.b.stored_playlists
def test_get_by_name_returns_unique_match(self):
playlist = Playlist(name='b')
self.s.playlists = [Playlist(name='a'), playlist]
self.assertEqual(playlist, self.s.get(name='b'))
def test_get_by_name_returns_first_of_multiple_matches(self):
playlist = Playlist(name='b')
self.s.playlists = [playlist, Playlist(name='a'), Playlist(name='b')]
try:
self.s.get(name='b')
self.fail(u'Should raise LookupError if multiple matches')
except LookupError as e:
self.assertEqual(u'"name=b" match multiple playlists', e[0])
def test_get_by_id_raises_keyerror_if_no_match(self):
self.s.playlists = [Playlist(name='a'), Playlist(name='b')]
try:
self.s.get(name='c')
self.fail(u'Should raise LookupError if no match')
except LookupError as e:
self.assertEqual(u'"name=c" match no playlists', e[0])

View File

@ -0,0 +1,50 @@
import unittest
from mopidy.mixers.denon import DenonMixer
class DenonMixerDeviceMock(object):
def __init__(self):
self._open = True
self.ret_val = bytes('MV00\r')
def write(self, x):
if x[2] != '?':
self.ret_val = bytes(x)
def read(self, x):
return self.ret_val
def readline(self):
return self.ret_val
def isOpen(self):
return self._open
def open(self):
self._open = True
class DenonMixerTest(unittest.TestCase):
def setUp(self):
self.m = DenonMixer()
self.m._device = DenonMixerDeviceMock()
def test_volume_set_to_min(self):
self.m.volume = 0
self.assertEqual(self.m.volume, 0)
def test_volume_set_to_max(self):
self.m.volume = 100
self.assertEqual(self.m.volume, 99)
def test_volume_set_to_below_min_results_in_min(self):
self.m.volume = -10
self.assertEqual(self.m.volume, 0)
def test_volume_set_to_above_max_results_in_max(self):
self.m.volume = 110
self.assertEqual(self.m.volume, 99)
def test_reopen_device(self):
self.m._device._open = False
self.m.volume = 10
self.assertTrue(self.m._device._open)

View File

@ -0,0 +1,26 @@
import unittest
from mopidy.mixers.dummy import DummyMixer
class BaseMixerTest(unittest.TestCase):
def setUp(self):
self.m = DummyMixer()
def test_volume_is_None_initially(self):
self.assertEqual(self.m.volume, None)
def test_volume_set_to_min(self):
self.m.volume = 0
self.assertEqual(self.m.volume, 0)
def test_volume_set_to_max(self):
self.m.volume = 100
self.assertEqual(self.m.volume, 100)
def test_volume_set_to_below_min_results_in_min(self):
self.m.volume = -10
self.assertEqual(self.m.volume, 0)
def test_volume_set_to_above_max_results_in_max(self):
self.m.volume = 110
self.assertEqual(self.m.volume, 100)

243
tests/models_test.py Normal file
View File

@ -0,0 +1,243 @@
import datetime as dt
import unittest
from mopidy.models import Artist, Album, Track, Playlist
class ArtistTest(unittest.TestCase):
def test_uri(self):
uri = u'an_uri'
artist = Artist(uri=uri)
self.assertEqual(artist.uri, uri)
self.assertRaises(AttributeError, setattr, artist, 'uri', None)
def test_name(self):
name = u'a name'
artist = Artist(name=name)
self.assertEqual(artist.name, name)
self.assertRaises(AttributeError, setattr, artist, 'name', None)
class AlbumTest(unittest.TestCase):
def test_uri(self):
uri = u'an_uri'
album = Album(uri=uri)
self.assertEqual(album.uri, uri)
self.assertRaises(AttributeError, setattr, album, 'uri', None)
def test_name(self):
name = u'a name'
album = Album(name=name)
self.assertEqual(album.name, name)
self.assertRaises(AttributeError, setattr, album, 'name', None)
def test_artists(self):
artists = [Artist()]
album = Album(artists=artists)
self.assertEqual(album.artists, artists)
self.assertRaises(AttributeError, setattr, album, 'artists', None)
def test_num_tracks(self):
num_tracks = 11
album = Album(num_tracks=11)
self.assertEqual(album.num_tracks, num_tracks)
self.assertRaises(AttributeError, setattr, album, 'num_tracks', None)
class TrackTest(unittest.TestCase):
def test_uri(self):
uri = u'an_uri'
track = Track(uri=uri)
self.assertEqual(track.uri, uri)
self.assertRaises(AttributeError, setattr, track, 'uri', None)
def test_name(self):
name = u'a name'
track = Track(name=name)
self.assertEqual(track.name, name)
self.assertRaises(AttributeError, setattr, track, 'name', None)
def test_artists(self):
artists = [Artist(), Artist()]
track = Track(artists=artists)
self.assertEqual(track.artists, artists)
self.assertRaises(AttributeError, setattr, track, 'artists', None)
def test_album(self):
album = Album()
track = Track(album=album)
self.assertEqual(track.album, album)
self.assertRaises(AttributeError, setattr, track, 'album', None)
def test_track_no(self):
track_no = 7
track = Track(track_no=track_no)
self.assertEqual(track.track_no, track_no)
self.assertRaises(AttributeError, setattr, track, 'track_no', None)
def test_date(self):
date = dt.date(1977, 1, 1)
track = Track(date=date)
self.assertEqual(track.date, date)
self.assertRaises(AttributeError, setattr, track, 'date', None)
def test_length(self):
length = 137000
track = Track(length=length)
self.assertEqual(track.length, length)
self.assertRaises(AttributeError, setattr, track, 'length', None)
def test_bitrate(self):
bitrate = 160
track = Track(bitrate=bitrate)
self.assertEqual(track.bitrate, bitrate)
self.assertRaises(AttributeError, setattr, track, 'bitrate', None)
def test_id(self):
id = 17
track = Track(id=id)
self.assertEqual(track.id, id)
self.assertRaises(AttributeError, setattr, track, 'id', None)
def test_mpd_format_for_empty_track(self):
track = Track()
result = track.mpd_format()
self.assert_(('file', '') in result)
self.assert_(('Time', 0) in result)
self.assert_(('Artist', '') in result)
self.assert_(('Title', '') in result)
self.assert_(('Album', '') in result)
self.assert_(('Track', 0) in result)
self.assert_(('Date', '') in result)
self.assert_(('Pos', 0) in result)
self.assert_(('Id', 0) in result)
def test_mpd_format_for_nonempty_track(self):
track = Track(
uri=u'a uri',
artists=[Artist(name=u'an artist')],
name=u'a name',
album=Album(name=u'an album', num_tracks=13),
track_no=7,
date=dt.date(1977, 1, 1),
length=137000,
id=122,
)
result = track.mpd_format(position=9)
self.assert_(('file', 'a uri') in result)
self.assert_(('Time', 137) in result)
self.assert_(('Artist', 'an artist') in result)
self.assert_(('Title', 'a name') in result)
self.assert_(('Album', 'an album') in result)
self.assert_(('Track', '7/13') in result)
self.assert_(('Date', dt.date(1977, 1, 1)) in result)
self.assert_(('Pos', 9) in result)
self.assert_(('Id', 122) in result)
def test_mpd_format_artists(self):
track = Track(artists=[Artist(name=u'ABBA'), Artist(name=u'Beatles')])
self.assertEqual(track.mpd_format_artists(), u'ABBA, Beatles')
class PlaylistTest(unittest.TestCase):
def test_uri(self):
uri = u'an_uri'
playlist = Playlist(uri=uri)
self.assertEqual(playlist.uri, uri)
self.assertRaises(AttributeError, setattr, playlist, 'uri', None)
def test_name(self):
name = u'a name'
playlist = Playlist(name=name)
self.assertEqual(playlist.name, name)
self.assertRaises(AttributeError, setattr, playlist, 'name', None)
def test_tracks(self):
tracks = [Track(), Track(), Track()]
playlist = Playlist(tracks=tracks)
self.assertEqual(playlist.tracks, tracks)
self.assertRaises(AttributeError, setattr, playlist, 'tracks', None)
def test_length(self):
tracks = [Track(), Track(), Track()]
playlist = Playlist(tracks=tracks)
self.assertEqual(playlist.length, 3)
def test_last_modified(self):
last_modified = dt.datetime.now()
playlist = Playlist(last_modified=last_modified)
self.assertEqual(playlist.last_modified, last_modified)
self.assertRaises(AttributeError, setattr, playlist, 'last_modified',
None)
def test_mpd_format(self):
playlist = Playlist(tracks=[
Track(track_no=1), Track(track_no=2), Track(track_no=3)])
result = playlist.mpd_format()
self.assertEqual(len(result), 3)
def test_mpd_format_with_range(self):
playlist = Playlist(tracks=[
Track(track_no=1), Track(track_no=2), Track(track_no=3)])
result = playlist.mpd_format(1, 2)
self.assertEqual(len(result), 1)
self.assertEqual(dict(result[0])['Track'], 2)
def test_mpd_format_with_negative_start_and_no_end(self):
playlist = Playlist(tracks=[
Track(track_no=1), Track(track_no=2), Track(track_no=3)])
result = playlist.mpd_format(-1, None)
self.assertEqual(len(result), 1)
self.assertEqual(dict(result[0])['Track'], 3)
def test_mpd_format_with_negative_start_and_end(self):
playlist = Playlist(tracks=[
Track(track_no=1), Track(track_no=2), Track(track_no=3)])
result = playlist.mpd_format(-2, -1)
self.assertEqual(len(result), 1)
self.assertEqual(dict(result[0])['Track'], 2)
def test_with_new_uri(self):
tracks = [Track()]
last_modified = dt.datetime.now()
playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks,
last_modified=last_modified)
new_playlist = playlist.with_(uri=u'another uri')
self.assertEqual(new_playlist.uri, u'another uri')
self.assertEqual(new_playlist.name, u'a name')
self.assertEqual(new_playlist.tracks, tracks)
self.assertEqual(new_playlist.last_modified, last_modified)
def test_with_new_name(self):
tracks = [Track()]
last_modified = dt.datetime.now()
playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks,
last_modified=last_modified)
new_playlist = playlist.with_(name=u'another name')
self.assertEqual(new_playlist.uri, u'an uri')
self.assertEqual(new_playlist.name, u'another name')
self.assertEqual(new_playlist.tracks, tracks)
self.assertEqual(new_playlist.last_modified, last_modified)
def test_with_new_tracks(self):
tracks = [Track()]
last_modified = dt.datetime.now()
playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks,
last_modified=last_modified)
new_tracks = [Track(), Track()]
new_playlist = playlist.with_(tracks=new_tracks)
self.assertEqual(new_playlist.uri, u'an uri')
self.assertEqual(new_playlist.name, u'a name')
self.assertEqual(new_playlist.tracks, new_tracks)
self.assertEqual(new_playlist.last_modified, last_modified)
def test_with_new_last_modified(self):
tracks = [Track()]
last_modified = dt.datetime.now()
new_last_modified = last_modified + dt.timedelta(1)
playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks,
last_modified=last_modified)
new_playlist = playlist.with_(last_modified=new_last_modified)
self.assertEqual(new_playlist.uri, u'an uri')
self.assertEqual(new_playlist.name, u'a name')
self.assertEqual(new_playlist.tracks, tracks)
self.assertEqual(new_playlist.last_modified, new_last_modified)

0
tests/mpd/__init__.py Normal file
View File

View File

@ -0,0 +1,19 @@
import unittest
from mopidy.mpd import MpdAckError, MpdNotImplemented
class MpdExceptionsTest(unittest.TestCase):
def test_key_error_wrapped_in_mpd_ack_error(self):
try:
try:
raise KeyError(u'Track X not found')
except KeyError as e:
raise MpdAckError(e[0])
except MpdAckError as e:
self.assertEqual(e.message, u'Track X not found')
def test_mpd_not_implemented_is_a_mpd_ack_error(self):
try:
raise MpdNotImplemented
except MpdAckError as e:
self.assertEqual(e.message, u'Not implemented')

1147
tests/mpd/frontend_test.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,660 +0,0 @@
import unittest
from mopidy.backends.dummy import DummyBackend
from mopidy.exceptions import MpdAckError
from mopidy.models import Track, Playlist
from mopidy.mpd import handler
class DummySession(object):
def do_close(self):
pass
def do_kill(self):
pass
def stats_uptime(self):
return 0
class RequestHandlerTest(unittest.TestCase):
def setUp(self):
self.h = handler.MpdHandler(backend=DummyBackend())
def test_register_same_pattern_twice_fails(self):
func = lambda: None
try:
handler.register('a pattern')(func)
handler.register('a pattern')(func)
self.fail('Registering a pattern twice shoulde raise ValueError')
except ValueError:
pass
def test_handling_unknown_request_raises_exception(self):
try:
result = self.h.handle_request('an unhandled request')
self.fail(u'An unknown request should raise an exception')
except MpdAckError:
pass
def test_handling_known_request(self):
expected = 'magic'
handler._request_handlers['known request'] = lambda x: expected
result = self.h.handle_request('known request')
self.assert_(u'OK' in result)
self.assert_(expected in result)
class CommandListsTest(unittest.TestCase):
def setUp(self):
self.h = handler.MpdHandler(backend=DummyBackend())
def test_command_list_begin(self):
result = self.h.handle_request(u'command_list_begin')
self.assert_(result is None)
def test_command_list_end(self):
self.h.handle_request(u'command_list_begin')
result = self.h.handle_request(u'command_list_end')
self.assert_(u'OK' in result)
def test_command_list_with_ping(self):
self.h.handle_request(u'command_list_begin')
self.assertEquals([], self.h.command_list)
self.assertEquals(False, self.h.command_list_ok)
self.h.handle_request(u'ping')
self.assert_(u'ping' in self.h.command_list)
result = self.h.handle_request(u'command_list_end')
self.assert_(u'OK' in result)
self.assertEquals(False, self.h.command_list)
def test_command_list_ok_begin(self):
result = self.h.handle_request(u'command_list_ok_begin')
self.assert_(result is None)
def test_command_list_ok_with_ping(self):
self.h.handle_request(u'command_list_ok_begin')
self.assertEquals([], self.h.command_list)
self.assertEquals(True, self.h.command_list_ok)
self.h.handle_request(u'ping')
self.assert_(u'ping' in self.h.command_list)
result = self.h.handle_request(u'command_list_end')
self.assert_(u'list_OK' in result)
self.assert_(u'OK' in result)
self.assertEquals(False, self.h.command_list)
self.assertEquals(False, self.h.command_list_ok)
class StatusHandlerTest(unittest.TestCase):
def setUp(self):
self.b = DummyBackend()
self.s = DummySession()
self.h = handler.MpdHandler(backend=self.b, session=self.s)
def test_clearerror(self):
result = self.h.handle_request(u'clearerror')
self.assert_(u'ACK Not implemented' in result)
def test_currentsong(self):
result = self.h.handle_request(u'currentsong')
self.assert_(u'OK' in result)
def test_idle_without_subsystems(self):
result = self.h.handle_request(u'idle')
self.assert_(u'ACK Not implemented' in result)
def test_idle_with_subsystems(self):
result = self.h.handle_request(u'idle database playlist')
self.assert_(u'ACK Not implemented' in result)
def test_stats_command(self):
result = self.h.handle_request(u'stats')
self.assert_(u'OK' in result)
def test_stats_method(self):
result = self.h._stats()
self.assert_('artists' in result)
self.assert_(int(result['artists']) >= 0)
self.assert_('albums' in result)
self.assert_(int(result['albums']) >= 0)
self.assert_('songs' in result)
self.assert_(int(result['songs']) >= 0)
self.assert_('uptime' in result)
self.assert_(int(result['uptime']) >= 0)
self.assert_('db_playtime' in result)
self.assert_(int(result['db_playtime']) >= 0)
self.assert_('db_update' in result)
self.assert_(int(result['db_update']) >= 0)
self.assert_('playtime' in result)
self.assert_(int(result['playtime']) >= 0)
def test_status_command(self):
result = self.h.handle_request(u'status')
self.assert_(u'OK' in result)
def test_status_method(self):
result = dict(self.h._status())
self.assert_('volume' in result)
self.assert_(int(result['volume']) in xrange(0, 101))
self.assert_('repeat' in result)
self.assert_(int(result['repeat']) in (0, 1))
self.assert_('random' in result)
self.assert_(int(result['random']) in (0, 1))
self.assert_('single' in result)
self.assert_(int(result['single']) in (0, 1))
self.assert_('consume' in result)
self.assert_(int(result['consume']) in (0, 1))
self.assert_('playlist' in result)
self.assert_(int(result['playlist']) in xrange(0, 2**31))
self.assert_('playlistlength' in result)
self.assert_(int(result['playlistlength']) >= 0)
self.assert_('xfade' in result)
self.assert_(int(result['xfade']) >= 0)
self.assert_('state' in result)
self.assert_(result['state'] in ('play', 'stop', 'pause'))
def test_status_method_when_playlist_loaded(self):
self.b._current_playlist = Playlist(tracks=[Track()])
result = dict(self.h._status())
self.assert_('song' in result)
self.assert_(int(result['song']) >= 0)
self.assert_('songid' in result)
self.assert_(int(result['songid']) >= 0)
def test_status_method_when_playing(self):
self.b.state = self.b.PLAY
result = dict(self.h._status())
self.assert_('time' in result)
(position, total) = result['time'].split(':')
position = int(position)
total = int(total)
self.assert_(position <= total)
class PlaybackOptionsHandlerTest(unittest.TestCase):
def setUp(self):
self.h = handler.MpdHandler(backend=DummyBackend())
def test_consume_off(self):
result = self.h.handle_request(u'consume "0"')
self.assert_(u'ACK Not implemented' in result)
def test_consume_on(self):
result = self.h.handle_request(u'consume "1"')
self.assert_(u'ACK Not implemented' in result)
def test_crossfade(self):
result = self.h.handle_request(u'crossfade "10"')
self.assert_(u'ACK Not implemented' in result)
def test_random_off(self):
result = self.h.handle_request(u'random "0"')
self.assert_(u'ACK Not implemented' in result)
def test_random_on(self):
result = self.h.handle_request(u'random "1"')
self.assert_(u'ACK Not implemented' in result)
def test_repeat_off(self):
result = self.h.handle_request(u'repeat "0"')
self.assert_(u'ACK Not implemented' in result)
def test_repeat_on(self):
result = self.h.handle_request(u'repeat "1"')
self.assert_(u'ACK Not implemented' in result)
def test_setvol_below_min(self):
result = self.h.handle_request(u'setvol "-10"')
self.assert_(u'ACK Not implemented' in result)
def test_setvol_min(self):
result = self.h.handle_request(u'setvol "0"')
self.assert_(u'ACK Not implemented' in result)
def test_setvol_middle(self):
result = self.h.handle_request(u'setvol "50"')
self.assert_(u'ACK Not implemented' in result)
def test_setvol_max(self):
result = self.h.handle_request(u'setvol "100"')
self.assert_(u'ACK Not implemented' in result)
def test_setvol_above_max(self):
result = self.h.handle_request(u'setvol "110"')
self.assert_(u'ACK Not implemented' in result)
def test_single_off(self):
result = self.h.handle_request(u'single "0"')
self.assert_(u'ACK Not implemented' in result)
def test_single_on(self):
result = self.h.handle_request(u'single "1"')
self.assert_(u'ACK Not implemented' in result)
def test_replay_gain_mode_off(self):
result = self.h.handle_request(u'replay_gain_mode "off"')
self.assert_(u'ACK Not implemented' in result)
def test_replay_gain_mode_track(self):
result = self.h.handle_request(u'replay_gain_mode "track"')
self.assert_(u'ACK Not implemented' in result)
def test_replay_gain_mode_album(self):
result = self.h.handle_request(u'replay_gain_mode "album"')
self.assert_(u'ACK Not implemented' in result)
def test_replay_gain_status_default(self):
expected = u'off'
result = self.h.handle_request(u'replay_gain_status')
self.assert_(u'OK' in result)
self.assert_(expected in result)
#def test_replay_gain_status_off(self):
# expected = u'off'
# self.h._replay_gain_mode(expected)
# result = self.h.handle_request(u'replay_gain_status')
# self.assert_(u'OK' in result)
# self.assert_(expected in result)
#def test_replay_gain_status_track(self):
# expected = u'track'
# self.h._replay_gain_mode(expected)
# result = self.h.handle_request(u'replay_gain_status')
# self.assert_(u'OK' in result)
# self.assert_(expected in result)
#def test_replay_gain_status_album(self):
# expected = u'album'
# self.h._replay_gain_mode(expected)
# result = self.h.handle_request(u'replay_gain_status')
# self.assert_(u'OK' in result)
# self.assert_(expected in result)
class PlaybackControlHandlerTest(unittest.TestCase):
def setUp(self):
self.b = DummyBackend()
self.h = handler.MpdHandler(backend=self.b)
def test_next(self):
result = self.h.handle_request(u'next')
self.assert_(u'OK' in result)
def test_pause_off(self):
self.h.handle_request(u'play')
self.h.handle_request(u'pause "1"')
result = self.h.handle_request(u'pause "0"')
self.assert_(u'OK' in result)
self.assertEquals(self.b.PLAY, self.b.state)
def test_pause_on(self):
self.h.handle_request(u'play')
result = self.h.handle_request(u'pause "1"')
self.assert_(u'OK' in result)
self.assertEquals(self.b.PAUSE, self.b.state)
def test_play_without_pos(self):
result = self.h.handle_request(u'play')
self.assert_(u'OK' in result)
self.assertEquals(self.b.PLAY, self.b.state)
def test_play_with_pos(self):
result = self.h.handle_request(u'play "0"')
self.assert_(u'OK' in result)
self.assertEquals(self.b.PLAY, self.b.state)
def test_playid(self):
result = self.h.handle_request(u'playid "0"')
self.assert_(u'OK' in result)
self.assertEquals(self.b.PLAY, self.b.state)
def test_previous(self):
result = self.h.handle_request(u'previous')
self.assert_(u'OK' in result)
def test_seek(self):
result = self.h.handle_request(u'seek "0" "30"')
self.assert_(u'ACK Not implemented' in result)
def test_seekid(self):
result = self.h.handle_request(u'seekid "0" "30"')
self.assert_(u'ACK Not implemented' in result)
def test_stop(self):
result = self.h.handle_request(u'stop')
self.assert_(u'OK' in result)
self.assertEquals(self.b.STOP, self.b.state)
class CurrentPlaylistHandlerTest(unittest.TestCase):
def setUp(self):
self.h = handler.MpdHandler(backend=DummyBackend())
def test_add(self):
result = self.h.handle_request(u'add "file:///dev/urandom"')
self.assert_(u'ACK Not implemented' in result)
def test_addid_without_songpos(self):
result = self.h.handle_request(u'addid "file:///dev/urandom"')
self.assert_(u'ACK Not implemented' in result)
def test_addid_with_songpos(self):
result = self.h.handle_request(u'addid "file:///dev/urandom" 0')
self.assert_(u'ACK Not implemented' in result)
def test_clear(self):
result = self.h.handle_request(u'clear')
self.assert_(u'ACK Not implemented' in result)
def test_delete_songpos(self):
result = self.h.handle_request(u'delete "5"')
self.assert_(u'ACK Not implemented' in result)
def test_delete_open_range(self):
result = self.h.handle_request(u'delete "10:"')
self.assert_(u'ACK Not implemented' in result)
def test_delete_closed_range(self):
result = self.h.handle_request(u'delete "10:20"')
self.assert_(u'ACK Not implemented' in result)
def test_deleteid(self):
result = self.h.handle_request(u'deleteid "0"')
self.assert_(u'ACK Not implemented' in result)
def test_move_songpos(self):
result = self.h.handle_request(u'move "5" "0"')
self.assert_(u'ACK Not implemented' in result)
def test_move_open_range(self):
result = self.h.handle_request(u'move "10:" "0"')
self.assert_(u'ACK Not implemented' in result)
def test_move_closed_range(self):
result = self.h.handle_request(u'move "10:20" "0"')
self.assert_(u'ACK Not implemented' in result)
def test_moveid(self):
result = self.h.handle_request(u'moveid "0" "10"')
self.assert_(u'ACK Not implemented' in result)
def test_playlist_returns_same_as_playlistinfo(self):
playlist_result = self.h.handle_request(u'playlist')
playlistinfo_result = self.h.handle_request(u'playlistinfo')
self.assertEquals(playlist_result, playlistinfo_result)
def test_playlistfind(self):
result = self.h.handle_request(u'playlistfind "tag" "needle"')
self.assert_(u'ACK Not implemented' in result)
def test_playlistid_without_songid(self):
result = self.h.handle_request(u'playlistid')
self.assert_(u'OK' in result)
def test_playlistid_with_songid(self):
result = self.h.handle_request(u'playlistid "10"')
self.assert_(u'OK' in result)
def test_playlistinfo_without_songpos_or_range(self):
result = self.h.handle_request(u'playlistinfo')
self.assert_(u'OK' in result)
def test_playlistinfo_with_songpos(self):
result = self.h.handle_request(u'playlistinfo "5"')
self.assert_(u'OK' in result)
def test_playlistinfo_with_open_range(self):
result = self.h.handle_request(u'playlistinfo "10:"')
self.assert_(u'OK' in result)
def test_playlistinfo_with_closed_range(self):
result = self.h.handle_request(u'playlistinfo "10:20"')
self.assert_(u'OK' in result)
def test_playlistsearch(self):
result = self.h.handle_request(u'playlistsearch "tag" "needle"')
self.assert_(u'ACK Not implemented' in result)
def test_plchanges(self):
result = self.h.handle_request(u'plchanges "0"')
self.assert_(u'OK' in result)
def test_plchangesposid(self):
result = self.h.handle_request(u'plchangesposid "0"')
self.assert_(u'ACK Not implemented' in result)
def test_shuffle_without_range(self):
result = self.h.handle_request(u'shuffle')
self.assert_(u'ACK Not implemented' in result)
def test_shuffle_with_open_range(self):
result = self.h.handle_request(u'shuffle "10:"')
self.assert_(u'ACK Not implemented' in result)
def test_shuffle_with_closed_range(self):
result = self.h.handle_request(u'shuffle "10:20"')
self.assert_(u'ACK Not implemented' in result)
def test_swap(self):
result = self.h.handle_request(u'swap "10" "20"')
self.assert_(u'ACK Not implemented' in result)
def test_swapid(self):
result = self.h.handle_request(u'swapid "10" "20"')
self.assert_(u'ACK Not implemented' in result)
class StoredPlaylistsHandlerTest(unittest.TestCase):
def setUp(self):
self.h = handler.MpdHandler(backend=DummyBackend())
def test_listplaylist(self):
result = self.h.handle_request(u'listplaylist "name"')
self.assert_(u'ACK Not implemented' in result)
def test_listplaylistinfo(self):
result = self.h.handle_request(u'listplaylistinfo "name"')
self.assert_(u'ACK Not implemented' in result)
def test_listplaylists(self):
result = self.h.handle_request(u'listplaylists')
self.assert_(u'OK' in result)
def test_load(self):
result = self.h.handle_request(u'load "name"')
self.assert_(u'OK' in result)
def test_playlistadd(self):
result = self.h.handle_request(
u'playlistadd "name" "file:///dev/urandom"')
self.assert_(u'ACK Not implemented' in result)
def test_playlistclear(self):
result = self.h.handle_request(u'playlistclear "name"')
self.assert_(u'ACK Not implemented' in result)
def test_playlistdelete(self):
result = self.h.handle_request(u'playlistdelete "name" "5"')
self.assert_(u'ACK Not implemented' in result)
def test_playlistmove(self):
result = self.h.handle_request(u'playlistmove "name" "5" "10"')
self.assert_(u'ACK Not implemented' in result)
def test_rename(self):
result = self.h.handle_request(u'rename "old_name" "new_name"')
self.assert_(u'ACK Not implemented' in result)
def test_rm(self):
result = self.h.handle_request(u'rm "name"')
self.assert_(u'ACK Not implemented' in result)
def test_save(self):
result = self.h.handle_request(u'save "name"')
self.assert_(u'ACK Not implemented' in result)
class MusicDatabaseHandlerTest(unittest.TestCase):
def setUp(self):
self.h = handler.MpdHandler(backend=DummyBackend())
def test_count(self):
result = self.h.handle_request(u'count "tag" "needle"')
self.assert_(u'ACK Not implemented' in result)
def test_find_album(self):
result = self.h.handle_request(u'find "album" "what"')
self.assert_(u'ACK Not implemented' in result)
def test_find_artist(self):
result = self.h.handle_request(u'find "artist" "what"')
self.assert_(u'ACK Not implemented' in result)
def test_find_title(self):
result = self.h.handle_request(u'find "title" "what"')
self.assert_(u'ACK Not implemented' in result)
def test_find_else_should_fail(self):
try:
result = self.h.handle_request(u'find "somethingelse" "what"')
self.fail('Find with unknown type should fail')
except MpdAckError:
pass
def test_findadd(self):
result = self.h.handle_request(u'findadd "album" "what"')
self.assert_(u'ACK Not implemented' in result)
def test_list_artist(self):
result = self.h.handle_request(u'list "artist"')
self.assert_(u'ACK Not implemented' in result)
def test_list_artist_with_artist_should_fail(self):
try:
result = self.h.handle_request(u'list "artist" "anartist"')
self.fail(u'Listing artists filtered by an artist should fail')
except MpdAckError:
pass
def test_list_album_without_artist(self):
result = self.h.handle_request(u'list "album"')
self.assert_(u'ACK Not implemented' in result)
def test_list_album_with_artist(self):
result = self.h.handle_request(u'list "album" "anartist"')
self.assert_(u'ACK Not implemented' in result)
def test_listall(self):
result = self.h.handle_request(u'listall "file:///dev/urandom"')
self.assert_(u'ACK Not implemented' in result)
def test_listallinfo(self):
result = self.h.handle_request(u'listallinfo "file:///dev/urandom"')
self.assert_(u'ACK Not implemented' in result)
def test_lsinfo_without_path_returns_same_as_listplaylists(self):
lsinfo_result = self.h.handle_request(u'lsinfo')
listplaylists_result = self.h.handle_request(u'listplaylists')
self.assertEquals(lsinfo_result, listplaylists_result)
def test_lsinfo_with_path(self):
result = self.h.handle_request(u'lsinfo ""')
self.assert_(u'ACK Not implemented' in result)
def test_lsinfo_for_root_returns_same_as_listplaylists(self):
lsinfo_result = self.h.handle_request(u'lsinfo "/"')
listplaylists_result = self.h.handle_request(u'listplaylists')
self.assertEquals(lsinfo_result, listplaylists_result)
def test_search_album(self):
result = self.h.handle_request(u'search "album" "analbum"')
self.assert_(u'OK' in result)
def test_search_artist(self):
result = self.h.handle_request(u'search "artist" "anartist"')
self.assert_(u'OK' in result)
def test_search_filename(self):
result = self.h.handle_request(u'search "filename" "afilename"')
self.assert_(u'OK' in result)
def test_search_title(self):
result = self.h.handle_request(u'search "title" "atitle"')
self.assert_(u'OK' in result)
def test_search_else_should_fail(self):
try:
result = self.h.handle_request(u'search "sometype" "something"')
self.fail(u'Search with unknown type should fail')
except MpdAckError:
pass
def test_update_without_uri(self):
result = self.h.handle_request(u'update')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
def test_update_with_uri(self):
result = self.h.handle_request(u'update "file:///dev/urandom"')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
def test_rescan_without_uri(self):
result = self.h.handle_request(u'rescan')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
def test_rescan_with_uri(self):
result = self.h.handle_request(u'rescan "file:///dev/urandom"')
self.assert_(u'OK' in result)
self.assert_(u'updating_db: 0' in result)
class StickersHandlerTest(unittest.TestCase):
def setUp(self):
self.h = handler.MpdHandler(backend=DummyBackend())
pass # TODO
class ConnectionHandlerTest(unittest.TestCase):
def setUp(self):
self.h = handler.MpdHandler(session=DummySession(),
backend=DummyBackend())
def test_close(self):
result = self.h.handle_request(u'close')
self.assert_(u'OK' in result)
def test_empty_request(self):
result = self.h.handle_request(u'')
self.assert_(u'OK' in result)
def test_kill(self):
result = self.h.handle_request(u'kill')
self.assert_(u'OK' in result)
def test_password(self):
result = self.h.handle_request(u'password "secret"')
self.assert_(u'ACK Not implemented' in result)
def test_ping(self):
result = self.h.handle_request(u'ping')
self.assert_(u'OK' in result)
class AudioOutputHandlerTest(unittest.TestCase):
def setUp(self):
self.h = handler.MpdHandler(backend=DummyBackend())
pass # TODO
class ReflectionHandlerTest(unittest.TestCase):
def setUp(self):
self.h = handler.MpdHandler(backend=DummyBackend())
def test_urlhandlers(self):
result = self.h.handle_request(u'urlhandlers')
self.assert_(u'OK' in result)
result = result[0]
self.assert_('dummy:' in result)
pass # TODO

15
tests/version_test.py Normal file
View File

@ -0,0 +1,15 @@
from distutils.version import StrictVersion as SV
import unittest
from mopidy import get_version
class VersionTest(unittest.TestCase):
def test_current_version_is_parsable_as_a_strict_version_number(self):
SV(get_version())
def test_versions_can_be_strictly_ordered(self):
self.assert_(SV(get_version()) < SV('0.1.0a1'))
self.assert_(SV('0.1.0a1') < SV('0.1.0'))
self.assert_(SV('0.1.0') < SV('0.1.1'))
self.assert_(SV('0.1.1') < SV('0.2.0'))
self.assert_(SV('0.2.0') < SV('1.0.0'))