Merge branch 'master' into gstreamer
Conflicts: mopidy/backends/__init__.py mopidy/mpd/handler.py
This commit is contained in:
commit
2d65666d58
11
.gitignore
vendored
11
.gitignore
vendored
@ -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/
|
||||
|
||||
11
AUTHORS.rst
11
AUTHORS.rst
@ -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
4
MANIFEST.in
Normal file
@ -0,0 +1,4 @@
|
||||
include COPYING
|
||||
include *.rst
|
||||
include requirements*.txt
|
||||
recursive-include docs *.rst
|
||||
@ -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
5
bin/mopidy
Normal 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
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
37
docs/_static/thread_communication.txt
vendored
Normal 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
15
docs/_templates/layout.html
vendored
Normal 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 %}
|
||||
@ -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
8
docs/api/index.rst
Normal file
@ -0,0 +1,8 @@
|
||||
*****************
|
||||
API documentation
|
||||
*****************
|
||||
|
||||
.. toctree::
|
||||
:glob:
|
||||
|
||||
**
|
||||
96
docs/api/mixers.rst
Normal file
96
docs/api/mixers.rst
Normal 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
|
||||
@ -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
22
docs/api/mpd.rst
Normal 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
27
docs/api/settings.rst
Normal 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
1
docs/authors.rst
Normal file
@ -0,0 +1 @@
|
||||
.. include:: ../AUTHORS.rst
|
||||
10
docs/autodoc_private_members.py
Normal file
10
docs/autodoc_private_members.py
Normal 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
|
||||
@ -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.
|
||||
|
||||
18
docs/conf.py
18
docs/conf.py
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
162
docs/development/contributing.rst
Normal file
162
docs/development/contributing.rst
Normal 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.
|
||||
10
docs/development/index.rst
Normal file
10
docs/development/index.rst
Normal file
@ -0,0 +1,10 @@
|
||||
***********
|
||||
Development
|
||||
***********
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 3
|
||||
|
||||
roadmap
|
||||
contributing
|
||||
internals
|
||||
59
docs/development/internals.rst
Normal file
59
docs/development/internals.rst
Normal 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
|
||||
58
docs/development/roadmap.rst
Normal file
58
docs/development/roadmap.rst
Normal 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.
|
||||
@ -6,9 +6,11 @@ Contents
|
||||
.. toctree::
|
||||
:maxdepth: 3
|
||||
|
||||
installation/index
|
||||
changes
|
||||
installation
|
||||
development
|
||||
development/index
|
||||
api/index
|
||||
authors
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
@ -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``.
|
||||
79
docs/installation/despotify.rst
Normal file
79
docs/installation/despotify.rst
Normal 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
107
docs/installation/index.rst
Normal 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``.
|
||||
67
docs/installation/libspotify.rst
Normal file
67
docs/installation/libspotify.rst
Normal 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',)
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
35
mopidy/mixers/__init__.py
Normal 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
18
mopidy/mixers/alsa.py
Normal 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
62
mopidy/mixers/denon.py
Normal 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
13
mopidy/mixers/dummy.py
Normal 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
200
mopidy/mixers/nad.py
Normal 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
34
mopidy/mixers/osa.py
Normal 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())
|
||||
249
mopidy/models.py
249
mopidy/models.py
@ -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)
|
||||
|
||||
@ -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
1493
mopidy/mpd/frontend.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||
@ -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)
|
||||
|
||||
@ -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
53
mopidy/process.py
Normal 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)
|
||||
@ -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
93
mopidy/utils.py
Normal 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
2
requirements-docs.txt
Normal file
@ -0,0 +1,2 @@
|
||||
Sphinx
|
||||
pygraphviz
|
||||
1
requirements-external-mixers.txt
Normal file
1
requirements-external-mixers.txt
Normal file
@ -0,0 +1 @@
|
||||
pyserial
|
||||
2
requirements-tests.txt
Normal file
2
requirements-tests.txt
Normal file
@ -0,0 +1,2 @@
|
||||
coverage
|
||||
nose
|
||||
8
setup.cfg
Normal file
8
setup.cfg
Normal 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
69
setup.py
Normal 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',
|
||||
],
|
||||
)
|
||||
@ -1 +0,0 @@
|
||||
-e bzr+http://liw.iki.fi/bzr/coverage-test-runner/trunk/#egg=CoverageTestRunner
|
||||
@ -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()
|
||||
|
||||
46
tests/backends/base_test.py
Normal file
46
tests/backends/base_test.py
Normal 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)
|
||||
99
tests/backends/get_test.py
Normal file
99
tests/backends/get_test.py
Normal 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])
|
||||
50
tests/mixers/denon_test.py
Normal file
50
tests/mixers/denon_test.py
Normal 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)
|
||||
26
tests/mixers/dummy_test.py
Normal file
26
tests/mixers/dummy_test.py
Normal 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
243
tests/models_test.py
Normal 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
0
tests/mpd/__init__.py
Normal file
19
tests/mpd/exception_test.py
Normal file
19
tests/mpd/exception_test.py
Normal 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
1147
tests/mpd/frontend_test.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
15
tests/version_test.py
Normal 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'))
|
||||
Loading…
Reference in New Issue
Block a user