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
|
*.swp
|
||||||
.coverage
|
.coverage
|
||||||
.idea
|
.idea
|
||||||
docs/_build
|
.noseids
|
||||||
local_settings.py
|
MANIFEST
|
||||||
|
build/
|
||||||
|
cover/
|
||||||
|
coverage.xml
|
||||||
|
dist/
|
||||||
|
docs/_build/
|
||||||
|
nosetests.xml
|
||||||
pip-log.txt
|
pip-log.txt
|
||||||
spotify_appkey.key
|
|
||||||
src/
|
src/
|
||||||
tmp/
|
tmp/
|
||||||
|
|||||||
11
AUTHORS.rst
11
AUTHORS.rst
@ -3,5 +3,12 @@ Authors
|
|||||||
|
|
||||||
Contributors to Mopidy in the order of appearance:
|
Contributors to Mopidy in the order of appearance:
|
||||||
|
|
||||||
* Stein Magnus Jodal <stein.magnus@jodal.no>
|
- Stein Magnus Jodal <stein.magnus@jodal.no>
|
||||||
* Johannes Knutsen <johannes@knutseninfo.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
|
can search for music in Spotify's vast archive, manage Spotify play lists and
|
||||||
play music from Spotify.
|
play music from Spotify.
|
||||||
|
|
||||||
Mopidy is currently under development. Unless you want to contribute to the
|
To install Mopidy, check out
|
||||||
development, you should probably wait for our first release before trying out
|
`the installation docs <http://www.mopidy.com/docs/installation/>`_.
|
||||||
Mopidy.
|
|
||||||
|
|
||||||
* `Source code <http://github.com/jodal/mopidy>`_
|
|
||||||
* `Documentation <http://www.mopidy.com/>`_
|
* `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/>`_
|
* 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::
|
The backend and its controllers
|
||||||
This is our *planned* backend API, and not the current API.
|
===============================
|
||||||
|
|
||||||
.. module:: mopidy.backends
|
.. graph:: backend_relations
|
||||||
:synopsis: Interface between Mopidy and its various backends.
|
|
||||||
|
|
||||||
.. class:: BaseBackend()
|
backend -- current_playlist
|
||||||
|
backend -- library
|
||||||
|
backend -- playback
|
||||||
|
backend -- stored_playlists
|
||||||
|
|
||||||
.. attribute:: current_playlist
|
|
||||||
|
|
||||||
The current playlist controller. An instance of
|
Backend API
|
||||||
:class:`BaseCurrentPlaylistController`.
|
===========
|
||||||
|
|
||||||
.. 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
|
:mod:`mopidy.backends.despotify` -- Despotify backend
|
||||||
:class:`BaseStoredPlaylistsController`.
|
-----------------------------------------------------
|
||||||
|
|
||||||
.. 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
|
:mod:`mopidy.backends.dummy` -- Dummy backend
|
||||||
playlist.
|
---------------------------------------------
|
||||||
|
|
||||||
:param track: track to add
|
.. automodule:: mopidy.backends.dummy
|
||||||
:type track: :class:`mopidy.models.Track`
|
:synopsis: Dummy backend used for testing.
|
||||||
:param at_position: position in current playlist to add track
|
:members:
|
||||||
:type at_position: int or :class:`None`
|
|
||||||
|
|
||||||
.. method:: clear()
|
|
||||||
|
|
||||||
Clear the current playlist.
|
GStreamer backend
|
||||||
|
-----------------
|
||||||
|
|
||||||
.. method:: load(playlist)
|
``GstreamerBackend`` is pending merge from `adamcik/mopidy/gstreamer
|
||||||
|
<http://github.com/adamcik/mopidy/tree/gstreamer>`_.
|
||||||
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`
|
|
||||||
|
|||||||
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
|
.. automodule:: mopidy.models
|
||||||
:synopsis: Immutable data 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.
|
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,
|
# 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
|
# 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.
|
# 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__) + '/../'))
|
sys.path.append(os.path.abspath(os.path.dirname(__file__) + '/../'))
|
||||||
|
|
||||||
|
import mopidy
|
||||||
|
|
||||||
# -- General configuration -----------------------------------------------------
|
# -- General configuration -----------------------------------------------------
|
||||||
|
|
||||||
# Add any Sphinx extension module names here, as strings. They can be extensions
|
# Add any Sphinx extension module names here, as strings. They can be extensions
|
||||||
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
|
# 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.
|
# Add any paths that contain templates here, relative to this directory.
|
||||||
templates_path = ['_templates']
|
templates_path = ['_templates']
|
||||||
@ -44,10 +48,11 @@ copyright = u'2010, Stein Magnus Jodal'
|
|||||||
# |version| and |release|, also used in various other places throughout the
|
# |version| and |release|, also used in various other places throughout the
|
||||||
# built documents.
|
# built documents.
|
||||||
#
|
#
|
||||||
# The short X.Y version.
|
|
||||||
version = '0.1'
|
|
||||||
# The full version, including alpha/beta/rc tags.
|
# 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
|
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||||
# for a list of supported languages.
|
# for a list of supported languages.
|
||||||
@ -84,7 +89,7 @@ exclude_trees = ['_build']
|
|||||||
pygments_style = 'sphinx'
|
pygments_style = 'sphinx'
|
||||||
|
|
||||||
# A list of ignored prefixes for module index sorting.
|
# A list of ignored prefixes for module index sorting.
|
||||||
#modindex_common_prefix = []
|
modindex_common_prefix = ['mopidy.']
|
||||||
|
|
||||||
|
|
||||||
# -- Options for HTML output ---------------------------------------------------
|
# -- 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,
|
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
|
||||||
# using the given strftime format.
|
# 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
|
# If true, SmartyPants will be used to convert quotes and dashes to
|
||||||
# typographically correct entities.
|
# typographically correct entities.
|
||||||
@ -192,3 +197,4 @@ latex_documents = [
|
|||||||
|
|
||||||
# If false, no module index is generated.
|
# If false, no module index is generated.
|
||||||
#latex_use_modindex = True
|
#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::
|
.. toctree::
|
||||||
:maxdepth: 3
|
:maxdepth: 3
|
||||||
|
|
||||||
|
installation/index
|
||||||
changes
|
changes
|
||||||
installation
|
development/index
|
||||||
development
|
api/index
|
||||||
|
authors
|
||||||
|
|
||||||
Indices and tables
|
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 import settings as raw_settings
|
||||||
from mopidy.exceptions import ConfigError
|
|
||||||
|
|
||||||
def get_version():
|
def get_version():
|
||||||
return u'0'
|
return u'0.1.0a0'
|
||||||
|
|
||||||
def get_mpd_protocol_version():
|
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):
|
def __getattr__(self, attr):
|
||||||
if not hasattr(settings, attr):
|
if attr.isupper() and not hasattr(raw_settings, attr):
|
||||||
raise ConfigError(u'Setting "%s" is not set.' % attr)
|
raise SettingsError(u'Setting "%s" is not set.' % attr)
|
||||||
value = getattr(settings, attr)
|
value = getattr(raw_settings, attr)
|
||||||
if type(value) != bool and not value:
|
if type(value) != bool and not value:
|
||||||
raise ConfigError(u'Setting "%s" is empty.' % attr)
|
raise SettingsError(u'Setting "%s" is empty.' % attr)
|
||||||
if type(value) == unicode:
|
|
||||||
value = value.encode('utf-8')
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
config = Config()
|
settings = Settings()
|
||||||
|
|||||||
@ -1,23 +1,39 @@
|
|||||||
import asyncore
|
import asyncore
|
||||||
import logging
|
import logging
|
||||||
|
import multiprocessing
|
||||||
|
import optparse
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0,
|
sys.path.insert(0,
|
||||||
os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
|
os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
|
||||||
|
|
||||||
from mopidy import config
|
from mopidy import get_version, settings, SettingsError
|
||||||
from mopidy.exceptions import ConfigError
|
from mopidy.process import CoreProcess
|
||||||
from mopidy.mpd.server import MpdServer
|
from mopidy.utils import get_class, get_or_create_dotdir
|
||||||
|
|
||||||
logger = logging.getLogger('mopidy')
|
logger = logging.getLogger('mopidy.main')
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
_setup_logging(2)
|
options, args = _parse_options()
|
||||||
backend = _get_backend(config.BACKEND)
|
_setup_logging(options.verbosity_level)
|
||||||
MpdServer(backend=backend)
|
get_or_create_dotdir('~/.mopidy/')
|
||||||
|
core_queue = multiprocessing.Queue()
|
||||||
|
get_class(settings.SERVER)(core_queue)
|
||||||
|
core = CoreProcess(core_queue)
|
||||||
|
core.start()
|
||||||
asyncore.loop()
|
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):
|
def _setup_logging(verbosity_level):
|
||||||
if verbosity_level == 0:
|
if verbosity_level == 0:
|
||||||
level = logging.WARNING
|
level = logging.WARNING
|
||||||
@ -25,24 +41,17 @@ def _setup_logging(verbosity_level):
|
|||||||
level = logging.DEBUG
|
level = logging.DEBUG
|
||||||
else:
|
else:
|
||||||
level = logging.INFO
|
level = logging.INFO
|
||||||
logging.basicConfig(
|
logging.basicConfig(format=settings.CONSOLE_LOG_FORMAT, level=level)
|
||||||
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
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
try:
|
try:
|
||||||
main()
|
main()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
sys.exit('\nInterrupted by user')
|
logger.info(u'Interrupted by user')
|
||||||
except ConfigError, e:
|
sys.exit(0)
|
||||||
sys.exit('%s' % e)
|
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 logging
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from mopidy.exceptions import MpdNotImplemented
|
from mopidy import settings
|
||||||
from mopidy.models import Playlist
|
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):
|
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
|
current_playlist = None
|
||||||
|
|
||||||
|
#: The library controller. An instance of :class:`BaseLibraryController`.
|
||||||
library = None
|
library = None
|
||||||
|
|
||||||
|
#: The sound mixer. An instance of :class:`mopidy.mixers.BaseMixer`.
|
||||||
|
mixer = None
|
||||||
|
|
||||||
|
#: The playback controller. An instance of :class:`BasePlaybackController`.
|
||||||
playback = None
|
playback = None
|
||||||
|
|
||||||
|
#: The stored playlists controller. An instance of
|
||||||
|
#: :class:`BaseStoredPlaylistsController`.
|
||||||
stored_playlists = None
|
stored_playlists = None
|
||||||
|
|
||||||
|
#: List of URI prefixes this backend can handle.
|
||||||
uri_handlers = []
|
uri_handlers = []
|
||||||
|
|
||||||
def destroy(self):
|
|
||||||
self.playback.destroy()
|
|
||||||
|
|
||||||
class BaseCurrentPlaylistController(object):
|
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):
|
def __init__(self, backend):
|
||||||
self.backend = backend
|
self.backend = backend
|
||||||
self.version = 0
|
|
||||||
self.playlist = Playlist()
|
self.playlist = Playlist()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def playlist(self):
|
def playlist(self):
|
||||||
return self._playlist
|
"""The currently loaded :class:`mopidy.models.Playlist`."""
|
||||||
|
return copy(self._playlist)
|
||||||
|
|
||||||
@playlist.setter
|
@playlist.setter
|
||||||
def playlist(self, playlist):
|
def playlist(self, new_playlist):
|
||||||
self._playlist = playlist
|
self._playlist = new_playlist
|
||||||
self.version += 1
|
self.version += 1
|
||||||
self.backend.playback.new_playlist_loaded_callback()
|
|
||||||
|
|
||||||
def add(self, uri, at_position=None):
|
def add(self, track, at_position=None):
|
||||||
raise NotImplementedError
|
"""
|
||||||
|
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):
|
def clear(self):
|
||||||
|
"""Clear the current playlist."""
|
||||||
self.backend.playback.stop()
|
self.backend.playback.stop()
|
||||||
|
self.backend.playback.current_track = None
|
||||||
self.playlist = Playlist()
|
self.playlist = Playlist()
|
||||||
|
|
||||||
def get_by_id(self, id):
|
def get(self, **criteria):
|
||||||
matches = filter(lambda t: t.id == id, self.playlist.tracks)
|
"""
|
||||||
if matches:
|
Get track by given criterias from current playlist.
|
||||||
return matches[0]
|
|
||||||
else:
|
|
||||||
raise KeyError('Track with ID "%s" not found' % id)
|
|
||||||
|
|
||||||
def get_by_url(self, uri):
|
Raises :exc:`LookupError` if a unique match is not found.
|
||||||
matches = filter(lambda t: t.uri == uri, self.playlist.tracks)
|
|
||||||
if matches:
|
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]
|
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:
|
else:
|
||||||
raise KeyError('Track with URI "%s" not found' % uri)
|
raise LookupError(u'"%s" match multiple tracks' % criteria_string)
|
||||||
|
|
||||||
def load(self, playlist):
|
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
|
self.playlist = playlist
|
||||||
|
|
||||||
def move(self, start, end, to_position):
|
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
|
tracks = self.playlist.tracks
|
||||||
|
|
||||||
if start == end:
|
|
||||||
end += 1
|
|
||||||
|
|
||||||
new_tracks = tracks[:start] + tracks[end:]
|
new_tracks = tracks[:start] + tracks[end:]
|
||||||
|
|
||||||
for track in tracks[start:end]:
|
for track in tracks[start:end]:
|
||||||
new_tracks.insert(to_position, track)
|
new_tracks.insert(to_position, track)
|
||||||
to_position += 1
|
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):
|
:param track: track to remove
|
||||||
tracks = filter(lambda t: t != track, self.playlist.tracks)
|
:type track: :class:`mopidy.models.Track`
|
||||||
|
"""
|
||||||
self.playlist = Playlist(tracks=tracks)
|
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):
|
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]
|
before = tracks[:start or 0]
|
||||||
shuffled = tracks[start:end]
|
shuffled = tracks[start:end]
|
||||||
after = tracks[end or len(tracks):]
|
after = tracks[end or len(tracks):]
|
||||||
|
|
||||||
random.shuffle(shuffled)
|
random.shuffle(shuffled)
|
||||||
|
self.playlist = self.playlist.with_(tracks=before+shuffled+after)
|
||||||
|
|
||||||
self.playlist = Playlist(tracks=before+shuffled+after)
|
|
||||||
|
|
||||||
class BasePlaybackController(object):
|
class BaseLibraryController(object):
|
||||||
PAUSED = 'paused'
|
"""
|
||||||
PLAYING = 'playing'
|
:param backend: backend the controller is a part of
|
||||||
STOPPED = 'stopped'
|
:type backend: :class:`BaseBackend`
|
||||||
|
"""
|
||||||
state = STOPPED
|
|
||||||
repeat = False
|
|
||||||
random = False
|
|
||||||
consume = False
|
|
||||||
volume = None
|
|
||||||
|
|
||||||
def __init__(self, backend):
|
def __init__(self, backend):
|
||||||
self.backend = 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
|
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
|
raise NotImplementedError
|
||||||
|
|
||||||
def new_playlist_loaded_callback(self):
|
def refresh(self, uri=None):
|
||||||
self.current_track = None
|
"""
|
||||||
|
Refresh library. Limit to URI and below if an URI is given.
|
||||||
|
|
||||||
if self.state == self.PLAYING:
|
:param uri: directory or track URI
|
||||||
self.play()
|
:type uri: string
|
||||||
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):
|
|
||||||
raise NotImplementedError
|
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
|
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
|
@property
|
||||||
def next_track(self):
|
def next_track(self):
|
||||||
playlist = self.backend.current_playlist.playlist
|
"""
|
||||||
|
The next :class:`mopidy.models.Track` in the 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
|
|
||||||
|
|
||||||
|
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:
|
if self.current_track is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if self.playlist_position - 1 < 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return playlist.tracks[self.playlist_position - 1]
|
return self.backend.current_playlist.playlist.tracks[
|
||||||
|
self.playlist_position + 1]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def playlist_position(self):
|
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:
|
try:
|
||||||
return playlist.tracks.index(self.current_track)
|
return self.backend.current_playlist.playlist.tracks.index(
|
||||||
|
self.current_track)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
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
|
@property
|
||||||
def time_position(self):
|
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
|
raise NotImplementedError
|
||||||
|
|
||||||
def destroy(self):
|
def play(self, track=None):
|
||||||
pass
|
"""
|
||||||
|
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
|
import spytify
|
||||||
|
|
||||||
from mopidy import config
|
from mopidy import settings
|
||||||
from mopidy.backends import BaseBackend
|
from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController,
|
||||||
|
BaseLibraryController, BasePlaybackController,
|
||||||
|
BaseStoredPlaylistsController)
|
||||||
from mopidy.models import Artist, Album, Track, Playlist
|
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'
|
ENCODING = 'utf-8'
|
||||||
|
|
||||||
class DespotifyBackend(BaseBackend):
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
super(DespotifyBackend, self).__init__(*args, **kwargs)
|
super(DespotifyBackend, self).__init__(*args, **kwargs)
|
||||||
logger.info(u'Connecting to Spotify')
|
self.current_playlist = DespotifyCurrentPlaylistController(backend=self)
|
||||||
self.spotify = spytify.Spytify(
|
self.library = DespotifyLibraryController(backend=self)
|
||||||
config.SPOTIFY_USERNAME, config.SPOTIFY_PASSWORD)
|
self.playback = DespotifyPlaybackController(backend=self)
|
||||||
self.cache_stored_playlists()
|
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')
|
logger.info(u'Caching stored playlists')
|
||||||
playlists = []
|
playlists = []
|
||||||
for spotify_playlist in self.spotify.stored_playlists:
|
for spotify_playlist in self.backend.spotify.stored_playlists:
|
||||||
playlists.append(self._to_mopidy_playlist(spotify_playlist))
|
playlists.append(
|
||||||
|
DespotifyTranslator.to_mopidy_playlist(spotify_playlist))
|
||||||
self._playlists = playlists
|
self._playlists = playlists
|
||||||
logger.debug(u'Available playlists: %s',
|
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):
|
class DespotifyTranslator(object):
|
||||||
return 0 # TODO
|
@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(
|
return Artist(
|
||||||
uri=spotify_artist.get_uri(),
|
uri=spotify_artist.get_uri(),
|
||||||
name=spotify_artist.name.decode(ENCODING)
|
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))
|
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:
|
if dt.MINYEAR <= int(spotify_track.year) <= dt.MAXYEAR:
|
||||||
date = dt.date(spotify_track.year, 1, 1)
|
date = dt.date(spotify_track.year, 1, 1)
|
||||||
else:
|
else:
|
||||||
date = None
|
date = None
|
||||||
return Track(
|
return Track(
|
||||||
uri=spotify_track.get_uri(),
|
uri=spotify_track.get_uri(),
|
||||||
title=spotify_track.title.decode(ENCODING),
|
name=spotify_track.title.decode(ENCODING),
|
||||||
artists=[self._to_mopidy_artist(a) for a in spotify_track.artists],
|
artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists],
|
||||||
album=self._to_mopidy_album(spotify_track.album),
|
album=cls.to_mopidy_album(spotify_track.album),
|
||||||
track_no=spotify_track.tracknumber,
|
track_no=spotify_track.tracknumber,
|
||||||
date=date,
|
date=date,
|
||||||
length=spotify_track.length,
|
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(
|
return Playlist(
|
||||||
uri=spotify_playlist.get_uri(),
|
uri=spotify_playlist.get_uri(),
|
||||||
name=spotify_playlist.name.decode(ENCODING),
|
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):
|
class DespotifySessionManager(spytify.Spytify):
|
||||||
self._current_song_pos += 1
|
DESPOTIFY_NEW_TRACK = 1
|
||||||
self.spotify.play(self.spotify.lookup(self._current_track.uri))
|
DESPOTIFY_TIME_TELL = 2
|
||||||
return True
|
DESPOTIFY_END_OF_PLAYLIST = 3
|
||||||
|
DESPOTIFY_TRACK_PLAY_ERROR = 4
|
||||||
|
|
||||||
def _pause(self):
|
def __init__(self, *args, **kwargs):
|
||||||
self.spotify.pause()
|
kwargs['callback'] = self.callback
|
||||||
return True
|
self.core_queue = kwargs.pop('core_queue')
|
||||||
|
super(DespotifySessionManager, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
def _play(self):
|
def callback(self, signal, data):
|
||||||
if self._current_track is not None:
|
if signal == self.DESPOTIFY_END_OF_PLAYLIST:
|
||||||
self.spotify.play(self.spotify.lookup(self._current_track.uri))
|
logger.debug('Despotify signalled end of playlist')
|
||||||
return True
|
self.core_queue.put({'command': 'end_of_track'})
|
||||||
else:
|
elif signal == self.DESPOTIFY_TRACK_PLAY_ERROR:
|
||||||
return False
|
logger.error('Despotify signalled track play error')
|
||||||
|
|
||||||
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()
|
|
||||||
|
|||||||
@ -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):
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
super(DummyBackend, self).__init__(*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):
|
class DummyCurrentPlaylistController(BaseCurrentPlaylistController):
|
||||||
return [u'dummy:']
|
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):
|
def _next(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _pause(self):
|
def _pause(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _play(self):
|
def _play(self, track):
|
||||||
return True
|
|
||||||
|
|
||||||
def _play_id(self, songid):
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _play_pos(self, songpos):
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _previous(self):
|
def _previous(self):
|
||||||
@ -27,3 +50,7 @@ class DummyBackend(BaseBackend):
|
|||||||
|
|
||||||
def _resume(self):
|
def _resume(self):
|
||||||
return True
|
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 datetime as dt
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import multiprocessing
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from spotify import Link
|
from spotify import Link
|
||||||
from spotify.manager import SpotifySessionManager
|
from spotify.manager import SpotifySessionManager
|
||||||
from spotify.alsahelper import AlsaController
|
from spotify.alsahelper import AlsaController
|
||||||
|
|
||||||
from mopidy import config
|
from mopidy import get_version, settings
|
||||||
from mopidy.backends import BaseBackend
|
from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController,
|
||||||
|
BaseLibraryController, BasePlaybackController,
|
||||||
|
BaseStoredPlaylistsController)
|
||||||
from mopidy.models import Artist, Album, Track, Playlist
|
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'
|
ENCODING = 'utf-8'
|
||||||
|
|
||||||
class LibspotifyBackend(BaseBackend):
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
super(LibspotifyBackend, self).__init__(*args, **kwargs)
|
super(LibspotifyBackend, self).__init__(*args, **kwargs)
|
||||||
self._next_id = 0
|
self.current_playlist = LibspotifyCurrentPlaylistController(
|
||||||
self._id_to_uri_map = {}
|
backend=self)
|
||||||
self._uri_to_id_map = {}
|
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')
|
logger.info(u'Connecting to Spotify')
|
||||||
self.spotify = LibspotifySessionManager(
|
spotify = LibspotifySessionManager(
|
||||||
config.SPOTIFY_USERNAME, config.SPOTIFY_PASSWORD, backend=self)
|
settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD,
|
||||||
self.spotify.start()
|
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:
|
class LibspotifyLibraryController(BaseLibraryController):
|
||||||
return self._uri_to_id_map[spotify_uri]
|
def search(self, type, what):
|
||||||
|
if type is u'any':
|
||||||
|
query = what
|
||||||
else:
|
else:
|
||||||
id = self._next_id
|
query = u'%s:%s' % (type, what)
|
||||||
self._next_id += 1
|
my_end, other_end = multiprocessing.Pipe()
|
||||||
self._id_to_uri_map[id] = spotify_uri
|
self.backend.spotify.search(query.encode(ENCODING), other_end)
|
||||||
self._uri_to_id_map[spotify_uri] = id
|
my_end.poll(None)
|
||||||
return id
|
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):
|
find_exact = search
|
||||||
return Artist(
|
|
||||||
uri=str(Link.from_artist(spotify_artist)),
|
|
||||||
name=spotify_artist.name().decode(ENCODING),
|
|
||||||
)
|
|
||||||
|
|
||||||
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):
|
def _pause(self):
|
||||||
# TODO
|
# TODO
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _play(self):
|
def _play(self, track):
|
||||||
if self._current_track is not None:
|
if self.state == self.PLAYING:
|
||||||
self._play_current_track()
|
self.stop()
|
||||||
return True
|
if track.uri is None:
|
||||||
else:
|
|
||||||
return False
|
return False
|
||||||
|
self.backend.spotify.session.load(
|
||||||
def _play_id(self, songid):
|
Link.from_string(track.uri).as_track())
|
||||||
matches = filter(lambda t: t.id == songid, self._current_playlist)
|
self.backend.spotify.session.play(1)
|
||||||
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()
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _resume(self):
|
def _resume(self):
|
||||||
@ -123,62 +100,142 @@ class LibspotifyBackend(BaseBackend):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _stop(self):
|
def _stop(self):
|
||||||
self.spotify.session.play(0)
|
self.backend.spotify.session.play(0)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Status querying
|
|
||||||
|
|
||||||
def status_bitrate(self):
|
class LibspotifyStoredPlaylistsController(BaseStoredPlaylistsController):
|
||||||
return 320
|
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):
|
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)
|
SpotifySessionManager.__init__(self, username, password)
|
||||||
threading.Thread.__init__(self)
|
threading.Thread.__init__(self)
|
||||||
self.backend = backend
|
self.core_queue = core_queue
|
||||||
|
self.connected = threading.Event()
|
||||||
self.audio = AlsaController()
|
self.audio = AlsaController()
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
self.connect()
|
self.connect()
|
||||||
|
|
||||||
def logged_in(self, session, error):
|
def logged_in(self, session, error):
|
||||||
|
"""Callback used by pyspotify"""
|
||||||
logger.info('Logged in')
|
logger.info('Logged in')
|
||||||
self.session = session
|
self.session = session
|
||||||
try:
|
self.connected.set()
|
||||||
self.playlists = session.playlist_container()
|
|
||||||
logger.debug('Got playlist container')
|
|
||||||
except Exception, e:
|
|
||||||
logger.exception(e)
|
|
||||||
|
|
||||||
def logged_out(self, session):
|
def logged_out(self, session):
|
||||||
|
"""Callback used by pyspotify"""
|
||||||
logger.info('Logged out')
|
logger.info('Logged out')
|
||||||
|
|
||||||
def metadata_updated(self, session):
|
def metadata_updated(self, session):
|
||||||
logger.debug('Metadata updated')
|
"""Callback used by pyspotify"""
|
||||||
self.backend.update_stored_playlists()
|
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):
|
def connection_error(self, session, error):
|
||||||
|
"""Callback used by pyspotify"""
|
||||||
logger.error('Connection error: %s', error)
|
logger.error('Connection error: %s', error)
|
||||||
|
|
||||||
def message_to_user(self, session, message):
|
def message_to_user(self, session, message):
|
||||||
|
"""Callback used by pyspotify"""
|
||||||
logger.info(message)
|
logger.info(message)
|
||||||
|
|
||||||
def notify_main_thread(self, session):
|
def notify_main_thread(self, session):
|
||||||
|
"""Callback used by pyspotify"""
|
||||||
logger.debug('Notify main thread')
|
logger.debug('Notify main thread')
|
||||||
|
|
||||||
def music_delivery(self, *args, **kwargs):
|
def music_delivery(self, *args, **kwargs):
|
||||||
|
"""Callback used by pyspotify"""
|
||||||
self.audio.music_delivery(*args, **kwargs)
|
self.audio.music_delivery(*args, **kwargs)
|
||||||
|
|
||||||
def play_token_lost(self, session):
|
def play_token_lost(self, session):
|
||||||
|
"""Callback used by pyspotify"""
|
||||||
logger.debug('Play token lost')
|
logger.debug('Play token lost')
|
||||||
|
self.core_queue.put({'command': 'stop_playback'})
|
||||||
|
|
||||||
def log_message(self, session, data):
|
def log_message(self, session, data):
|
||||||
|
"""Callback used by pyspotify"""
|
||||||
logger.debug(data)
|
logger.debug(data)
|
||||||
|
|
||||||
def end_of_track(self, session):
|
def end_of_track(self, session):
|
||||||
|
"""Callback used by pyspotify"""
|
||||||
logger.debug('End of track')
|
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
|
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
|
:param uri: artist URI
|
||||||
:type uri: string
|
:type uri: string
|
||||||
@ -8,22 +26,14 @@ class Artist(object):
|
|||||||
:type name: string
|
:type name: string
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, uri=None, name=None):
|
#: The artist URI. Read-only.
|
||||||
self._uri = None
|
uri = None
|
||||||
self._name = name
|
|
||||||
|
|
||||||
@property
|
#: The artist name. Read-only.
|
||||||
def uri(self):
|
name = None
|
||||||
"""The artist URI. Read-only."""
|
|
||||||
return self._uri
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
"""The artist name. Read-only."""
|
|
||||||
return self._name
|
|
||||||
|
|
||||||
|
|
||||||
class Album(object):
|
class Album(ImmutableObject):
|
||||||
"""
|
"""
|
||||||
:param uri: album URI
|
:param uri: album URI
|
||||||
:type uri: string
|
:type uri: string
|
||||||
@ -35,39 +45,31 @@ class Album(object):
|
|||||||
:type num_tracks: integer
|
:type num_tracks: integer
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, uri=None, name=None, artists=None, num_tracks=0):
|
#: The album URI. Read-only.
|
||||||
self._uri = uri
|
uri = None
|
||||||
self._name = name
|
|
||||||
self._artists = artists or []
|
|
||||||
self._num_tracks = num_tracks
|
|
||||||
|
|
||||||
@property
|
#: The album name. Read-only.
|
||||||
def uri(self):
|
name = None
|
||||||
"""The album URI. Read-only."""
|
|
||||||
return self._uri
|
|
||||||
|
|
||||||
@property
|
#: The number of tracks in the album. Read-only.
|
||||||
def name(self):
|
num_tracks = 0
|
||||||
"""The album name. Read-only."""
|
|
||||||
return self._name
|
def __init__(self, *args, **kwargs):
|
||||||
|
self._artists = kwargs.pop('artists', [])
|
||||||
|
super(Album, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def artists(self):
|
def artists(self):
|
||||||
"""List of :class:`Artist` elements. Read-only."""
|
"""List of :class:`Artist` elements. Read-only."""
|
||||||
return copy(self._artists)
|
return copy(self._artists)
|
||||||
|
|
||||||
@property
|
|
||||||
def num_tracks(self):
|
|
||||||
"""The number of tracks in the album. Read-only."""
|
|
||||||
return self._num_tracks
|
|
||||||
|
|
||||||
|
class Track(ImmutableObject):
|
||||||
class Track(object):
|
|
||||||
"""
|
"""
|
||||||
:param uri: track URI
|
:param uri: track URI
|
||||||
:type uri: string
|
:type uri: string
|
||||||
:param title: track title
|
:param name: track name
|
||||||
:type title: string
|
:type name: string
|
||||||
:param artists: track artists
|
:param artists: track artists
|
||||||
:type artists: list of :class:`Artist`
|
:type artists: list of :class:`Artist`
|
||||||
:param album: track album
|
:param album: track album
|
||||||
@ -84,64 +86,40 @@ class Track(object):
|
|||||||
:type id: integer
|
:type id: integer
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, uri=None, title=None, artists=None, album=None,
|
#: The track URI. Read-only.
|
||||||
track_no=0, date=None, length=None, bitrate=None, id=None):
|
uri = 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
|
|
||||||
|
|
||||||
@property
|
#: The track name. Read-only.
|
||||||
def uri(self):
|
name = None
|
||||||
"""The track URI. Read-only."""
|
|
||||||
return self._uri
|
|
||||||
|
|
||||||
@property
|
#: The track :class:`Album`. Read-only.
|
||||||
def title(self):
|
album = None
|
||||||
"""The track title. Read-only."""
|
|
||||||
return self._title
|
#: 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
|
@property
|
||||||
def artists(self):
|
def artists(self):
|
||||||
"""List of :class:`Artist`. Read-only."""
|
"""List of :class:`Artist`. Read-only."""
|
||||||
return copy(self._artists)
|
return copy(self._artists)
|
||||||
|
|
||||||
@property
|
def mpd_format(self, position=0, search_result=False):
|
||||||
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):
|
|
||||||
"""
|
"""
|
||||||
Format track for output to MPD client.
|
Format track for output to MPD client.
|
||||||
|
|
||||||
@ -149,17 +127,23 @@ class Track(object):
|
|||||||
:type position: integer
|
:type position: integer
|
||||||
:rtype: list of two-tuples
|
:rtype: list of two-tuples
|
||||||
"""
|
"""
|
||||||
return [
|
result = [
|
||||||
('file', self.uri),
|
('file', self.uri or ''),
|
||||||
('Time', self.length // 1000),
|
('Time', self.length and (self.length // 1000) or 0),
|
||||||
('Artist', self.mpd_format_artists()),
|
('Artist', self.mpd_format_artists()),
|
||||||
('Title', self.title),
|
('Title', self.name or ''),
|
||||||
('Album', self.album.name),
|
('Album', self.album and self.album.name or ''),
|
||||||
('Track', '%d/%d' % (self.track_no, self.album.num_tracks)),
|
('Date', self.date or ''),
|
||||||
('Date', self.date),
|
|
||||||
('Pos', position),
|
|
||||||
('Id', self.id),
|
|
||||||
]
|
]
|
||||||
|
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):
|
def mpd_format_artists(self):
|
||||||
"""
|
"""
|
||||||
@ -170,7 +154,7 @@ class Track(object):
|
|||||||
return u', '.join([a.name for a in self.artists])
|
return u', '.join([a.name for a in self.artists])
|
||||||
|
|
||||||
|
|
||||||
class Playlist(object):
|
class Playlist(ImmutableObject):
|
||||||
"""
|
"""
|
||||||
:param uri: playlist URI
|
:param uri: playlist URI
|
||||||
:type uri: string
|
:type uri: string
|
||||||
@ -180,20 +164,20 @@ class Playlist(object):
|
|||||||
:type tracks: list of :class:`Track` elements
|
:type tracks: list of :class:`Track` elements
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, uri=None, name=None, tracks=None):
|
#: The playlist URI. Read-only.
|
||||||
self._uri = uri
|
uri = None
|
||||||
self._name = name
|
|
||||||
self._tracks = tracks or []
|
|
||||||
|
|
||||||
@property
|
#: The playlist name. Read-only.
|
||||||
def uri(self):
|
name = None
|
||||||
"""The playlist URI. Read-only."""
|
|
||||||
return self._uri
|
|
||||||
|
|
||||||
@property
|
#: The playlist modification time. Read-only.
|
||||||
def name(self):
|
#:
|
||||||
"""The playlist name. Read-only."""
|
#: :class:`datetime.datetime`, or :class:`None` if unknown.
|
||||||
return self._name
|
last_modified = None
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self._tracks = kwargs.pop('tracks', [])
|
||||||
|
super(Playlist, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tracks(self):
|
def tracks(self):
|
||||||
@ -205,15 +189,56 @@ class Playlist(object):
|
|||||||
"""The number of tracks in the playlist. Read-only."""
|
"""The number of tracks in the playlist. Read-only."""
|
||||||
return len(self._tracks)
|
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.
|
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
|
:rtype: list of lists of two-tuples
|
||||||
"""
|
"""
|
||||||
if end is None:
|
if start < 0:
|
||||||
end = self.length
|
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 = []
|
tracks = []
|
||||||
for track, position in zip(self.tracks, range(start, end)):
|
for track, position in zip(self.tracks[start:end],
|
||||||
tracks.append(track.mpd_format(position))
|
range(range_start, range_end)):
|
||||||
|
tracks.append(track.mpd_format(position, search_result))
|
||||||
return tracks
|
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 asyncore
|
||||||
import logging
|
import logging
|
||||||
|
import multiprocessing
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
|
|
||||||
from mopidy import config
|
from mopidy import get_mpd_protocol_version, settings
|
||||||
from mopidy.mpd.session import MpdSession
|
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):
|
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)
|
asyncore.dispatcher.__init__(self)
|
||||||
self.session_class = session_class
|
try:
|
||||||
self.backend = backend
|
self.core_queue = core_queue
|
||||||
self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
|
self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
self.set_reuse_addr()
|
self.set_reuse_addr()
|
||||||
self.bind((config.MPD_SERVER_HOSTNAME, config.MPD_SERVER_PORT))
|
self.bind((settings.SERVER_HOSTNAME, settings.SERVER_PORT))
|
||||||
self.listen(1)
|
self.listen(1)
|
||||||
self.started_at = int(time.time())
|
logger.info(u'MPD server running at [%s]:%s',
|
||||||
logger.info(u'Please connect to %s port %s using an MPD client.',
|
settings.SERVER_HOSTNAME, settings.SERVER_PORT)
|
||||||
config.MPD_SERVER_HOSTNAME, config.MPD_SERVER_PORT)
|
except IOError, e:
|
||||||
|
sys.exit('MPD server startup failed: %s' % e)
|
||||||
|
|
||||||
def handle_accept(self):
|
def handle_accept(self):
|
||||||
(client_socket, client_address) = self.accept()
|
(client_socket, client_address) = self.accept()
|
||||||
logger.info(u'Connection from: [%s]:%s', *client_address)
|
logger.info(u'MPD client connection from [%s]:%s', *client_address)
|
||||||
self.session_class(self, client_socket, client_address,
|
MpdSession(self, client_socket, client_address, self.core_queue)
|
||||||
backend=self.backend)
|
|
||||||
|
|
||||||
def handle_close(self):
|
def handle_close(self):
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
def do_kill(self):
|
|
||||||
logger.info(u'Received "kill". Shutting down.')
|
|
||||||
self.handle_close()
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
@property
|
class MpdSession(asynchat.async_chat):
|
||||||
def uptime(self):
|
"""
|
||||||
return int(time.time()) - self.started_at
|
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'
|
Available settings and their default values.
|
||||||
MPD_LINE_TERMINATOR = u'\n'
|
|
||||||
MPD_SERVER_HOSTNAME = u'localhost'
|
|
||||||
MPD_SERVER_PORT = 6600
|
|
||||||
|
|
||||||
BACKEND=u'mopidy.backends.despotify.DespotifyBackend'
|
.. warning::
|
||||||
#BACKEND=u'mopidy.backends.libspotify.LibspotifyBackend'
|
|
||||||
|
|
||||||
|
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''
|
SPOTIFY_USERNAME = u''
|
||||||
|
|
||||||
|
#: Your Spotify Premium password. Used by all Spotify backends.
|
||||||
SPOTIFY_PASSWORD = u''
|
SPOTIFY_PASSWORD = u''
|
||||||
|
|
||||||
try:
|
#: Path to your libspotify application key. Used by LibspotifyBackend.
|
||||||
from mopidy.local_settings import *
|
SPOTIFY_LIB_APPKEY = u'~/.mopidy/spotify_appkey.key'
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
#: 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 nose
|
||||||
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()
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
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