diff --git a/README.rst b/README.rst
index 766d5354..9bca444e 100644
--- a/README.rst
+++ b/README.rst
@@ -4,7 +4,7 @@ Mopidy
Mopidy is an `MPD `_ server with a
`Spotify `_ backend. Using a standard MPD client you
-can search for music in Spotify's wast archive, manage Spotify play lists and
+can search for music in Spotify's vast archive, manage Spotify play lists and
play music from Spotify.
Mopidy is currently under development. Unless you want to contribute to the
diff --git a/docs/_static/.placeholder b/docs/_static/.placeholder
new file mode 100644
index 00000000..e69de29b
diff --git a/docs/_themes/nature/static/nature.css_t b/docs/_themes/nature/static/nature.css_t
index 03b0379d..8762e019 100644
--- a/docs/_themes/nature/static/nature.css_t
+++ b/docs/_themes/nature/static/nature.css_t
@@ -10,8 +10,8 @@
body {
font-family: Arial, sans-serif;
font-size: 100%;
- background-color: #111;
- color: #555;
+ background-color: #111111;
+ color: #555555;
margin: 0;
padding: 0;
}
@@ -22,7 +22,7 @@ div.documentwrapper {
}
div.bodywrapper {
- margin: 0 0 0 230px;
+ margin: 0 0 0 300px;
}
hr{
@@ -30,14 +30,14 @@ hr{
}
div.document {
- background-color: #eee;
+ background-color: #fafafa;
}
div.body {
background-color: #ffffff;
color: #3E4349;
- padding: 0 30px 30px 30px;
- font-size: 0.8em;
+ padding: 1em 30px 30px 30px;
+ font-size: 0.9em;
}
div.footer {
@@ -49,25 +49,29 @@ div.footer {
}
div.footer a {
- color: #444;
- text-decoration: underline;
+ color: #444444;
}
div.related {
background-color: #6BA81E;
- line-height: 32px;
- color: #fff;
- text-shadow: 0px 1px 0 #444;
- font-size: 0.80em;
+ line-height: 36px;
+ color: #ffffff;
+ text-shadow: 0px 1px 0 #444444;
+ font-size: 1.1em;
}
div.related a {
color: #E2F3CC;
}
-
+
+div.related .right {
+ font-size: 0.9em;
+}
+
div.sphinxsidebar {
- font-size: 0.75em;
+ font-size: 0.9em;
line-height: 1.5em;
+ width: 300px
}
div.sphinxsidebarwrapper{
@@ -77,46 +81,46 @@ div.sphinxsidebarwrapper{
div.sphinxsidebar h3,
div.sphinxsidebar h4 {
font-family: Arial, sans-serif;
- color: #222;
+ color: #222222;
font-size: 1.2em;
- font-weight: normal;
+ font-weight: bold;
margin: 0;
padding: 5px 10px;
- background-color: #ddd;
text-shadow: 1px 1px 0 white
}
-div.sphinxsidebar h4{
- font-size: 1.1em;
-}
-
div.sphinxsidebar h3 a {
- color: #444;
+ color: #444444;
}
-
-
+
div.sphinxsidebar p {
- color: #888;
+ color: #888888;
padding: 5px 20px;
+ margin: 0.5em 0px;
}
div.sphinxsidebar p.topless {
}
div.sphinxsidebar ul {
- margin: 10px 20px;
+ margin: 10px 10px 10px 20px;
padding: 0;
- color: #000;
+ color: #000000;
}
div.sphinxsidebar a {
- color: #444;
+ color: #444444;
}
-
+
+div.sphinxsidebar a:hover {
+ color: #E32E00;
+}
+
div.sphinxsidebar input {
- border: 1px solid #ccc;
+ border: 1px solid #cccccc;
font-family: sans-serif;
- font-size: 1em;
+ font-size: 1.1em;
+ padding: 0.15em 0.3em;
}
div.sphinxsidebar input[type=text]{
@@ -132,7 +136,6 @@ a {
a:hover {
color: #E32E00;
- text-decoration: underline;
}
div.body h1,
@@ -142,20 +145,20 @@ div.body h4,
div.body h5,
div.body h6 {
font-family: Arial, sans-serif;
- background-color: #BED4EB;
font-weight: normal;
color: #212224;
margin: 30px 0px 10px 0px;
- padding: 5px 0 5px 10px;
- text-shadow: 0px 1px 0 white
+ padding: 5px 0 5px 0px;
+ text-shadow: 0px 1px 0 white;
+ border-bottom: 1px solid #C8D5E3;
}
-div.body h1 { border-top: 20px solid white; margin-top: 0; font-size: 200%; }
-div.body h2 { font-size: 150%; background-color: #C8D5E3; }
-div.body h3 { font-size: 120%; background-color: #D8DEE3; }
-div.body h4 { font-size: 110%; background-color: #D8DEE3; }
-div.body h5 { font-size: 100%; background-color: #D8DEE3; }
-div.body h6 { font-size: 100%; background-color: #D8DEE3; }
+div.body h1 { margin-top: 0; font-size: 200%; }
+div.body h2 { font-size: 150%; }
+div.body h3 { font-size: 120%; }
+div.body h4 { font-size: 110%; }
+div.body h5 { font-size: 100%; }
+div.body h6 { font-size: 100%; }
a.headerlink {
color: #c60f0f;
@@ -170,7 +173,7 @@ a.headerlink:hover {
}
div.body p, div.body dd, div.body li {
- line-height: 1.5em;
+ line-height: 1.8em;
}
div.admonition p.admonition-title + p {
@@ -182,22 +185,23 @@ div.highlight{
}
div.note {
- background-color: #eee;
- border: 1px solid #ccc;
+ background-color: #eeeeee;
+ border: 1px solid #cccccc;
}
div.seealso {
- background-color: #ffc;
- border: 1px solid #ff6;
+ background-color: #ffffcc;
+ border: 1px solid #ffff66;
}
div.topic {
- background-color: #eee;
+ background-color: #fafafa;
+ border-width: 0;
}
div.warning {
background-color: #ffe4e4;
- border: 1px solid #f66;
+ border: 1px solid #ff6666;
}
p.admonition-title {
@@ -210,20 +214,23 @@ p.admonition-title:after {
pre {
padding: 10px;
- background-color: White;
- color: #222;
- line-height: 1.2em;
- border: 1px solid #C6C9CB;
- font-size: 1.2em;
+ background-color: #fafafa;
+ color: #222222;
+ line-height: 1.5em;
+ font-size: 1.1em;
margin: 1.5em 0 1.5em 0;
- -webkit-box-shadow: 1px 1px 1px #d8d8d8;
- -moz-box-shadow: 1px 1px 1px #d8d8d8;
+ -webkit-box-shadow: 0px 0px 4px #d8d8d8;
+ -moz-box-shadow: 0px 0px 4px #d8d8d8;
+ box-shadow: 0px 0px 4px #d8d8d8;
}
tt {
- background-color: #ecf0f3;
- color: #222;
+ color: #222222;
padding: 1px 2px;
font-size: 1.2em;
font-family: monospace;
}
+
+#table-of-contents ul {
+ padding-left: 2em;
+}
diff --git a/docs/api/backends.rst b/docs/api/backends.rst
new file mode 100644
index 00000000..d5077082
--- /dev/null
+++ b/docs/api/backends.rst
@@ -0,0 +1,298 @@
+*************************************
+:mod:`mopidy.backends` -- Backend API
+*************************************
+
+.. warning::
+ This is our *planned* backend API, and not the current API.
+
+.. module:: mopidy.backends
+ :synopsis: Interface between Mopidy and its various backends.
+
+.. class:: BaseBackend()
+
+ .. attribute:: current_playlist
+
+ The current playlist controller. An instance of
+ :class:`BaseCurrentPlaylistController`.
+
+ .. attribute:: library
+
+ The library controller. An instance of :class:`BaseLibraryController`.
+
+ .. attribute:: playback
+
+ The playback controller. An instance of :class:`BasePlaybackController`.
+
+ .. attribute:: stored_playlists
+
+ The stored playlists controller. An instance of
+ :class:`BaseStoredPlaylistsController`.
+
+ .. attribute:: uri_handlers
+
+ List of URI prefixes this backend can handle.
+
+
+.. class:: BaseCurrentPlaylistController(backend)
+
+ :param backend: backend the controller is a part of
+ :type backend: :class:`BaseBackend`
+
+ .. method:: add(track, at_position=None)
+
+ Add the track to the end of, or at the given position in the current
+ playlist.
+
+ :param track: track to add
+ :type track: :class:`mopidy.models.Track`
+ :param at_position: position in current playlist to add track
+ :type at_position: int or :class:`None`
+
+ .. method:: clear()
+
+ Clear the current playlist.
+
+ .. method:: load(playlist)
+
+ Replace the current playlist with the given playlist.
+
+ :param playlist: playlist to load
+ :type playlist: :class:`mopidy.models.Playlist`
+
+ .. method:: move(start, end, to_position)
+
+ Move the tracks at positions in [``start``, ``end``] to
+ ``to_position``.
+
+ :param start: position of first track to move
+ :type start: int
+ :param end: position of 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 playlist, optionally a part of the playlist given by
+ ``start`` and ``end``.
+
+ :param start: position of first track to shuffle
+ :type start: int or :class:`None`
+ :param end: position of 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(id=None, position=None)
+
+ Play either the track with the given ID, the given position, or the
+ currently active track.
+
+ :param id: ID of track to play
+ :type id: int
+ :param position: position in current playlist of track to play
+ :type position: int
+
+ .. 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`
diff --git a/docs/api/models.rst b/docs/api/models.rst
new file mode 100644
index 00000000..75f9ab02
--- /dev/null
+++ b/docs/api/models.rst
@@ -0,0 +1,8 @@
+*********************************************
+:mod:`mopidy.models` -- Immutable data models
+*********************************************
+
+.. automodule:: mopidy.models
+ :synopsis: Immutable data models.
+ :members:
+ :undoc-members:
diff --git a/docs/conf.py b/docs/conf.py
index f0d29003..8cb63290 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -16,13 +16,13 @@ import sys, os
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
-#sys.path.append(os.path.abspath('.'))
+sys.path.append(os.path.abspath(os.path.dirname(__file__) + '/../'))
# -- General configuration -----------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
-extensions = []
+extensions = ['sphinx.ext.autodoc']
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@@ -147,7 +147,7 @@ html_static_path = ['_static']
#html_split_index = False
# If true, links to the reST sources are added to the pages.
-#html_show_sourcelink = True
+html_show_sourcelink = False
# If true, an OpenSearch description file will be output, and all pages will
# contain a tag referring to it. The value of this option must be the
diff --git a/docs/development.rst b/docs/development.rst
index 68239393..959dcd58 100644
--- a/docs/development.rst
+++ b/docs/development.rst
@@ -6,6 +6,15 @@ Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at
``irc.freenode.net`` and through `GitHub `_.
+API documentation
+=================
+
+.. toctree::
+ :glob:
+
+ api/*
+
+
Scope
=====
diff --git a/docs/index.rst b/docs/index.rst
index 08a9dacd..7c618dc3 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -14,5 +14,6 @@ Indices and tables
==================
* :ref:`genindex`
+* :ref:`modindex`
* :ref:`search`
diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py
index 0d68c68d..43fbbd5d 100644
--- a/mopidy/backends/__init__.py
+++ b/mopidy/backends/__init__.py
@@ -123,10 +123,6 @@ class BaseBackend(object):
return self.state
def status_time(self):
- # XXX This is only called when a client is connected, and is thus not a
- # complete solution
- if self._play_time_elapsed >= self.status_time_total() > 0:
- self.end_of_track()
return u'%s:%s' % (self._play_time_elapsed, self.status_time_total())
def status_time_total(self):
@@ -208,13 +204,13 @@ class BaseBackend(object):
# Current/single playlist methods
- def playlist_changes_since(self, version):
- return None
-
def playlist_load(self, name):
+ self._current_song_pos = None
matches = filter(lambda p: p.name == name, self._playlists)
if matches:
self._current_playlist = matches[0]
+ if self.state == self.PLAY:
+ self.play(songpos=0)
else:
self._current_playlist = None
diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py
index 646228c8..a60e2aac 100644
--- a/mopidy/backends/despotify.py
+++ b/mopidy/backends/despotify.py
@@ -120,4 +120,5 @@ class DespotifyBackend(BaseBackend):
def search(self, type, what):
query = u'%s:%s' % (type, what)
result = self.spotify.search(query.encode(ENCODING))
- return self._to_mopidy_playlist(result.playlist).mpd_format()
+ if result is not None:
+ return self._to_mopidy_playlist(result.playlist).mpd_format()
diff --git a/mopidy/models.py b/mopidy/models.py
index bd47c1ef..39212c8e 100644
--- a/mopidy/models.py
+++ b/mopidy/models.py
@@ -1,20 +1,40 @@
from copy import copy
class Artist(object):
+ """
+ :param uri: artist URI
+ :type uri: string
+ :param name: artist name
+ :type name: string
+ """
+
def __init__(self, uri=None, name=None):
self._uri = None
self._name = name
@property
def uri(self):
+ """The artist URI. Read-only."""
return self._uri
@property
def name(self):
+ """The artist name. Read-only."""
return self._name
class Album(object):
+ """
+ :param uri: album URI
+ :type uri: string
+ :param name: album name
+ :type name: string
+ :param artists: album artists
+ :type artists: list of :class:`Artist`
+ :param num_tracks: number of tracks in album
+ :type num_tracks: integer
+ """
+
def __init__(self, uri=None, name=None, artists=None, num_tracks=0):
self._uri = uri
self._name = name
@@ -23,24 +43,49 @@ class Album(object):
@property
def uri(self):
+ """The album URI. Read-only."""
return self._uri
@property
def name(self):
+ """The album name. Read-only."""
return self._name
@property
def artists(self):
+ """List of :class:`Artist` elements. Read-only."""
return copy(self._artists)
@property
def num_tracks(self):
+ """The number of tracks in the album. Read-only."""
return self._num_tracks
class Track(object):
+ """
+ :param uri: track URI
+ :type uri: string
+ :param title: track title
+ :type title: string
+ :param artists: track artists
+ :type artists: list of :class:`Artist`
+ :param album: track album
+ :type album: :class:`Album`
+ :param track_no: track number in album
+ :type track_no: integer
+ :param date: track release date
+ :type date: :class:`datetime.date`
+ :param length: track length in milliseconds
+ :type length: integer
+ :param bitrate: bitrate in kbit/s
+ :type bitrate: integer
+ :param id: track ID (unique and non-changing as long as the process lives)
+ :type id: integer
+ """
+
def __init__(self, uri=None, title=None, artists=None, album=None,
- track_no=0, date=None, length=None, id=None):
+ track_no=0, date=None, length=None, bitrate=None, id=None):
self._uri = uri
self._title = title
self._artists = artists or []
@@ -48,41 +93,62 @@ class Track(object):
self._track_no = track_no
self._date = date
self._length = length
+ self._bitrate = bitrate
self._id = id
@property
def uri(self):
+ """The track URI. Read-only."""
return self._uri
@property
def title(self):
+ """The track title. Read-only."""
return self._title
@property
def artists(self):
+ """List of :class:`Artist`. Read-only."""
return copy(self._artists)
@property
def album(self):
+ """The track :class:`Album`. Read-only."""
return self._album
@property
def track_no(self):
+ """The track number in album. Read-only."""
return self._track_no
@property
def date(self):
+ """The track release date. Read-only."""
return self._date
@property
def length(self):
+ """The track length in milliseconds. Read-only."""
return self._length
+ @property
+ def bitrate(self):
+ """The track's bitrate in kbit/s. Read-only."""
+ return self._bitrate
+
@property
def id(self):
+ """The track ID. Read-only."""
return self._id
def mpd_format(self, position=0):
+ """
+ Format track for output to MPD client.
+
+ :param position: track's position in playlist
+ :type position: integer
+ :rtype: list of two-tuples
+ """
return [
('file', self.uri),
('Time', self.length // 1000),
@@ -96,10 +162,24 @@ class Track(object):
]
def mpd_format_artists(self):
+ """
+ Format track artists for output to MPD client.
+
+ :rtype: string
+ """
return u', '.join([a.name for a in self.artists])
class Playlist(object):
+ """
+ :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
+ """
+
def __init__(self, uri=None, name=None, tracks=None):
self._uri = uri
self._name = name
@@ -107,21 +187,30 @@ class Playlist(object):
@property
def uri(self):
+ """The playlist URI. Read-only."""
return self._uri
@property
def name(self):
+ """The playlist name. Read-only."""
return self._name
@property
def tracks(self):
+ """List of :class:`Track` elements. Read-only."""
return copy(self._tracks)
@property
def length(self):
+ """The number of tracks in the playlist. Read-only."""
return len(self._tracks)
def mpd_format(self, start=0, end=None):
+ """
+ Format playlist for output to MPD client.
+
+ :rtype: list of lists of two-tuples
+ """
if end is None:
end = self.length
tracks = []
diff --git a/tests/backends/__init__.py b/tests/backends/__init__.py
new file mode 100644
index 00000000..cec58c73
--- /dev/null
+++ b/tests/backends/__init__.py
@@ -0,0 +1,48 @@
+from mopidy.models import Track
+
+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)