Merge develop into feature/threads-not-processes
This commit is contained in:
commit
318524be21
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,4 +8,5 @@ cover/
|
||||
coverage.xml
|
||||
dist/
|
||||
docs/_build/
|
||||
mopidy.log
|
||||
nosetests.xml
|
||||
|
||||
@ -8,11 +8,18 @@ A frontend is responsible for exposing Mopidy for a type of clients.
|
||||
Frontend API
|
||||
============
|
||||
|
||||
A stable frontend API is not available yet, as we've only implemented a single
|
||||
frontend module.
|
||||
.. warning::
|
||||
|
||||
A stable frontend API is not available yet, as we've only implemented a
|
||||
couple of frontend modules.
|
||||
|
||||
.. automodule:: mopidy.frontends.base
|
||||
:synopsis: Base class for frontends
|
||||
:members:
|
||||
|
||||
|
||||
Frontends
|
||||
=========
|
||||
|
||||
* :mod:`mopidy.frontends.lastfm`
|
||||
* :mod:`mopidy.frontends.mpd`
|
||||
|
||||
7
docs/api/frontends/lastfm.rst
Normal file
7
docs/api/frontends/lastfm.rst
Normal file
@ -0,0 +1,7 @@
|
||||
******************************
|
||||
:mod:`mopidy.frontends.lastfm`
|
||||
******************************
|
||||
|
||||
.. automodule:: mopidy.frontends.lastfm
|
||||
:synopsis: Last.fm scrobbler frontend
|
||||
:members:
|
||||
@ -5,6 +5,31 @@ Changes
|
||||
This change log is used to track all major changes to Mopidy.
|
||||
|
||||
|
||||
0.2.0 (in development)
|
||||
======================
|
||||
|
||||
No description yet.
|
||||
|
||||
**Important changes**
|
||||
|
||||
- Added a Last.fm scrobbler. See :mod:`mopidy.frontends.lastfm` for details.
|
||||
|
||||
**Changes**
|
||||
|
||||
- Simplify the default log format, :attr:`mopidy.settings.CONSOLE_LOG_FORMAT`.
|
||||
From a user's point of view: Less noise, more information.
|
||||
- Rename the :option:`--dump` command line option to
|
||||
:option:`--save-debug-log`.
|
||||
- Rename setting :attr:`mopidy.settings.DUMP_LOG_FORMAT` to
|
||||
:attr:`mopidy.settings.DEBUG_LOG_FORMAT` and use it for :option:`--verbose`
|
||||
too.
|
||||
- Rename setting :attr:`mopidy.settings.DUMP_LOG_FILENAME` to
|
||||
:attr:`mopidy.settings.DEBUG_LOG_FILENAME`.
|
||||
- MPD frontend:
|
||||
|
||||
- ``add ""`` and ``addid ""`` now behaves as expected.
|
||||
|
||||
|
||||
0.1.0 (2010-08-23)
|
||||
==================
|
||||
|
||||
|
||||
8
docs/clients/index.rst
Normal file
8
docs/clients/index.rst
Normal file
@ -0,0 +1,8 @@
|
||||
*******
|
||||
Clients
|
||||
*******
|
||||
|
||||
.. toctree::
|
||||
:glob:
|
||||
|
||||
**
|
||||
98
docs/clients/mpd.rst
Normal file
98
docs/clients/mpd.rst
Normal file
@ -0,0 +1,98 @@
|
||||
************************
|
||||
MPD client compatability
|
||||
************************
|
||||
|
||||
This is a list of MPD clients we either know works well with Mopidy, or that we
|
||||
know won't work well. For a more exhaustive list of MPD clients, see
|
||||
http://mpd.wikia.com/wiki/Clients.
|
||||
|
||||
|
||||
Console clients
|
||||
===============
|
||||
|
||||
mpc
|
||||
---
|
||||
|
||||
A command line client. Version 0.14 had some issues with Mopidy (see
|
||||
:issue:`5`), but 0.16 seems to work nicely.
|
||||
|
||||
ncmpc
|
||||
-----
|
||||
|
||||
A console client. Uses the ``idle`` command heavily, which Mopidy doesn't
|
||||
support yet. If you want a console client, use ncmpcpp instead.
|
||||
|
||||
ncmpcpp
|
||||
-------
|
||||
|
||||
A console client that generally works well with Mopidy, and is regularly used
|
||||
by Mopidy developers.
|
||||
|
||||
Search only works for ncmpcpp versions 0.5.1 and higher, and in two of the
|
||||
three search modes:
|
||||
|
||||
- "Match if tag contains search phrase (regexes supported)" -- Does not work.
|
||||
The client tries to fetch all known metadata and do the search client side.
|
||||
- "Match if tag contains searched phrase (no regexes)" -- Works.
|
||||
- "Match only if both values are the same" -- Works.
|
||||
|
||||
If you run Ubuntu 10.04 or older, you can fetch an updated version of ncmpcpp
|
||||
from `Launchpad <https://launchpad.net/ubuntu/+source/ncmpcpp>`_.
|
||||
|
||||
|
||||
|
||||
Graphical clients
|
||||
=================
|
||||
|
||||
GMPC
|
||||
----
|
||||
|
||||
A GTK+ client which works well with Mopidy, and is regularly used by Mopidy
|
||||
developers.
|
||||
|
||||
Sonata
|
||||
------
|
||||
|
||||
A GTK+ client. Generally works well with Mopidy.
|
||||
|
||||
Search does not work, because they do most of the search on the client side.
|
||||
See :issue:`1` for details.
|
||||
|
||||
|
||||
Android clients
|
||||
===============
|
||||
|
||||
BitMPC
|
||||
------
|
||||
|
||||
Works well with Mopidy.
|
||||
|
||||
Droid MPD
|
||||
---------
|
||||
|
||||
Works well with Mopidy.
|
||||
|
||||
MPDroid
|
||||
-------
|
||||
|
||||
Works well with Mopidy, and is regularly used by Mopidy developers.
|
||||
|
||||
PMix
|
||||
----
|
||||
|
||||
Works well with Mopidy.
|
||||
|
||||
ThreeMPD
|
||||
--------
|
||||
|
||||
Does not work well with Mopidy, because we haven't implemented ``listallinfo``
|
||||
yet.
|
||||
|
||||
|
||||
iPhone/iPod Touch clients
|
||||
=========================
|
||||
|
||||
MPod
|
||||
----
|
||||
|
||||
Works well with Mopidy as far as we've heard from users.
|
||||
@ -6,26 +6,34 @@ This is the current roadmap and collection of wild ideas for future Mopidy
|
||||
development. This is intended to be a living document and may change at any
|
||||
time.
|
||||
|
||||
Version 0.1
|
||||
===========
|
||||
|
||||
- Core MPD server functionality working. Gracefully handle clients' use of
|
||||
non-supported functionality.
|
||||
- Read-only support for Spotify through :mod:`mopidy.backends.libspotify`.
|
||||
- Initial support for local file playback through
|
||||
:mod:`mopidy.backends.local`. The state of local file playback will not
|
||||
block the release of 0.1.
|
||||
We intend to have about one timeboxed release every month. Thus, the roadmap is
|
||||
oriented around "soon" and "later" instead of mapping each feature to a future
|
||||
release.
|
||||
|
||||
|
||||
Version 0.2 and 0.3
|
||||
===================
|
||||
Possible targets for the next version
|
||||
=====================================
|
||||
|
||||
0.2 will be released when we reach one of the following two goals. 0.3 will be
|
||||
released when we reach the other goal.
|
||||
|
||||
- Write-support for Spotify. I.e. playlist management.
|
||||
- Reintroduce support for OS X. See :issue:`14` for details.
|
||||
- Support for using multiple Mopidy backends simultaneously. Should make it
|
||||
possible to have both Spotify tracks and local tracks in the same playlist.
|
||||
- MPD frontend:
|
||||
|
||||
- ``idle`` support.
|
||||
|
||||
- Spotify backend:
|
||||
|
||||
- Write-support for Spotify, i.e. playlist management.
|
||||
- Virtual directories with e.g. starred tracks from Spotify.
|
||||
- Support for 320 kbps audio.
|
||||
|
||||
- Local backend:
|
||||
|
||||
- Better library support.
|
||||
- A script for creating a tag cache.
|
||||
- An alternative to tag cache for caching metadata, i.e. Sqlite.
|
||||
|
||||
- **[DONE]** Last.fm scrobbling.
|
||||
|
||||
|
||||
Stuff we want to do, but not right now, and maybe never
|
||||
@ -45,15 +53,12 @@ Stuff we want to do, but not right now, and maybe never
|
||||
- Compatability:
|
||||
|
||||
- Run frontend tests against a real MPD server to ensure we are in sync.
|
||||
- Start working with MPD client maintainers to get rid of weird assumptions
|
||||
like only searching for first two letters and doing the rest of the
|
||||
filtering locally in the client (:issue:`1`), etc.
|
||||
|
||||
- Backends:
|
||||
|
||||
- `Last.fm <http://www.last.fm/api>`_
|
||||
- `WIMP <http://twitter.com/wimp/status/8975885632>`_
|
||||
- DNLA/UPnP to Mopidy can play music from other DNLA MediaServers.
|
||||
- DNLA/UPnP so Mopidy can play music from other DNLA MediaServers.
|
||||
|
||||
- Frontends:
|
||||
|
||||
@ -63,7 +68,7 @@ Stuff we want to do, but not right now, and maybe never
|
||||
- REST/JSON web service with a jQuery client as example application. Maybe
|
||||
based upon `Tornado <http://github.com/facebook/tornado>`_ and `jQuery
|
||||
Mobile <http://jquerymobile.com/>`_.
|
||||
- DNLA/UPnP to Mopidy can be controlled from i.e. TVs.
|
||||
- DNLA/UPnP so Mopidy can be controlled from i.e. TVs.
|
||||
- `XMMS2 <http://www.xmms2.org/>`_
|
||||
- LIRC frontend for controlling Mopidy with a remote.
|
||||
|
||||
|
||||
@ -7,6 +7,9 @@ User documentation
|
||||
:maxdepth: 3
|
||||
|
||||
installation/index
|
||||
settings
|
||||
running
|
||||
clients/index
|
||||
changes
|
||||
authors
|
||||
licenses
|
||||
|
||||
@ -54,6 +54,12 @@ Make sure you got the required dependencies installed.
|
||||
|
||||
- No additional dependencies.
|
||||
|
||||
- Optional dependencies:
|
||||
|
||||
- :mod:`mopidy.frontends.lastfm`
|
||||
|
||||
- pylast >= 4.3.0
|
||||
|
||||
|
||||
Install latest release
|
||||
======================
|
||||
@ -70,6 +76,9 @@ To later upgrade to the latest release::
|
||||
|
||||
If you for some reason can't use ``pip``, try ``easy_install``.
|
||||
|
||||
Next, you need to set a couple of :doc:`settings </settings>`, and then you're
|
||||
ready to :doc:`run Mopidy </running>`.
|
||||
|
||||
|
||||
Install development version
|
||||
===========================
|
||||
@ -92,58 +101,5 @@ To later update to the very latest version::
|
||||
For an introduction to ``git``, please visit `git-scm.com
|
||||
<http://git-scm.com/>`_.
|
||||
|
||||
|
||||
Settings
|
||||
========
|
||||
|
||||
Mopidy reads settings from the file ``~/.mopidy/settings.py``, where ``~``
|
||||
means your *home directory*. If your username is ``alice`` and you are running
|
||||
Linux, the settings file should probably be at
|
||||
``/home/alice/.mopidy/settings.py``.
|
||||
|
||||
You can either create this file yourself, or run the ``mopidy`` command, and it
|
||||
will create an empty settings file for you.
|
||||
|
||||
Music from Spotify
|
||||
------------------
|
||||
|
||||
If you are using the Spotify backend, which is the default, enter your Spotify
|
||||
Premium account's username and password into the file, like this::
|
||||
|
||||
SPOTIFY_USERNAME = u'myusername'
|
||||
SPOTIFY_PASSWORD = u'mysecret'
|
||||
|
||||
Music from local storage
|
||||
------------------------
|
||||
|
||||
If you want use Mopidy to play music you have locally at your machine instead
|
||||
of using Spotify, you need to change the backend from the default to
|
||||
:mod:`mopidy.backends.local` by adding the following line to your settings
|
||||
file::
|
||||
|
||||
BACKENDS = (u'mopidy.backends.local.LocalBackend',)
|
||||
|
||||
You may also want to change some of the ``LOCAL_*`` settings. See
|
||||
:mod:`mopidy.settings`, for a full list of available settings.
|
||||
|
||||
Connecting from other machines on the network
|
||||
---------------------------------------------
|
||||
|
||||
As a secure default, Mopidy only accepts connections from ``localhost``. If you
|
||||
want to open it for connections from other machines on your network, see
|
||||
the documentation for :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`.
|
||||
|
||||
|
||||
Running Mopidy
|
||||
==============
|
||||
|
||||
To start Mopidy, simply open a terminal and run::
|
||||
|
||||
mopidy
|
||||
|
||||
When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to
|
||||
accept connections by any MPD client. You can find tons of MPD clients at
|
||||
http://mpd.wikia.com/wiki/Clients. We use GMPC and ncmpcpp during development.
|
||||
The first is a GUI client, and the second is a terminal client.
|
||||
|
||||
To stop Mopidy, press ``CTRL+C``.
|
||||
Next, you need to set a couple of :doc:`settings </settings>`, and then you're
|
||||
ready to :doc:`run Mopidy </running>`.
|
||||
|
||||
13
docs/running.rst
Normal file
13
docs/running.rst
Normal file
@ -0,0 +1,13 @@
|
||||
**************
|
||||
Running Mopidy
|
||||
**************
|
||||
|
||||
To start Mopidy, simply open a terminal and run::
|
||||
|
||||
mopidy
|
||||
|
||||
When Mopidy says ``MPD server running at [127.0.0.1]:6600`` it's ready to
|
||||
accept connections by any MPD client. Check out our non-exhaustive
|
||||
:doc:`/clients/mpd` list to find recommended clients.
|
||||
|
||||
To stop Mopidy, press ``CTRL+C``.
|
||||
55
docs/settings.rst
Normal file
55
docs/settings.rst
Normal file
@ -0,0 +1,55 @@
|
||||
********
|
||||
Settings
|
||||
********
|
||||
|
||||
Mopidy reads settings from the file ``~/.mopidy/settings.py``, where ``~``
|
||||
means your *home directory*. If your username is ``alice`` and you are running
|
||||
Linux, the settings file should probably be at
|
||||
``/home/alice/.mopidy/settings.py``.
|
||||
|
||||
You can either create this file yourself, or run the ``mopidy`` command, and it
|
||||
will create an empty settings file for you.
|
||||
|
||||
|
||||
Music from Spotify
|
||||
==================
|
||||
|
||||
If you are using the Spotify backend, which is the default, enter your Spotify
|
||||
Premium account's username and password into the file, like this::
|
||||
|
||||
SPOTIFY_USERNAME = u'myusername'
|
||||
SPOTIFY_PASSWORD = u'mysecret'
|
||||
|
||||
|
||||
Music from local storage
|
||||
========================
|
||||
|
||||
If you want use Mopidy to play music you have locally at your machine instead
|
||||
of using Spotify, you need to change the backend from the default to
|
||||
:mod:`mopidy.backends.local` by adding the following line to your settings
|
||||
file::
|
||||
|
||||
BACKENDS = (u'mopidy.backends.local.LocalBackend',)
|
||||
|
||||
You may also want to change some of the ``LOCAL_*`` settings. See
|
||||
:mod:`mopidy.settings`, for a full list of available settings.
|
||||
|
||||
|
||||
Connecting from other machines on the network
|
||||
=============================================
|
||||
|
||||
As a secure default, Mopidy only accepts connections from ``localhost``. If you
|
||||
want to open it for connections from other machines on your network, see
|
||||
the documentation for :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`.
|
||||
|
||||
|
||||
Scrobbling tracks to Last.fm
|
||||
============================
|
||||
|
||||
If you want to submit the tracks you are playing to your `Last.fm
|
||||
<http://www.last.fm/>`_ profile, make sure you've installed the dependencies
|
||||
found at :mod:`mopidy.frontends.lastfm` and add the following to your settings
|
||||
file::
|
||||
|
||||
LASTFM_USERNAME = u'myusername'
|
||||
LASTFM_PASSWORD = u'mysecret'
|
||||
@ -3,7 +3,7 @@ if not (2, 6) <= sys.version_info < (3,):
|
||||
sys.exit(u'Mopidy requires Python >= 2.6, < 3')
|
||||
|
||||
def get_version():
|
||||
return u'0.1.0'
|
||||
return u'0.2.0a1'
|
||||
|
||||
class MopidyException(Exception):
|
||||
def __init__(self, message, *args, **kwargs):
|
||||
@ -22,6 +22,9 @@ class MopidyException(Exception):
|
||||
class SettingsError(MopidyException):
|
||||
pass
|
||||
|
||||
class OptionalDependencyError(MopidyException):
|
||||
pass
|
||||
|
||||
from mopidy import settings as default_settings_module
|
||||
from mopidy.utils.settings import SettingsProxy
|
||||
settings = SettingsProxy(default_settings_module)
|
||||
|
||||
@ -23,17 +23,17 @@ class BaseBackend(object):
|
||||
:param core_queue: a queue for sending messages to
|
||||
:class:`mopidy.process.CoreProcess`
|
||||
:type core_queue: :class:`multiprocessing.Queue`
|
||||
:param output_queue: a queue for sending messages to the output process
|
||||
:type output_queue: :class:`multiprocessing.Queue`
|
||||
:param output: the audio output
|
||||
:type output: :class:`mopidy.outputs.gstreamer.GStreamerOutput` or similar
|
||||
:param mixer_class: either a mixer class, or :class:`None` to use the mixer
|
||||
defined in settings
|
||||
:type mixer_class: a subclass of :class:`mopidy.mixers.BaseMixer` or
|
||||
:class:`None`
|
||||
"""
|
||||
|
||||
def __init__(self, core_queue=None, output_queue=None, mixer_class=None):
|
||||
def __init__(self, core_queue=None, output=None, mixer_class=None):
|
||||
self.core_queue = core_queue
|
||||
self.output_queue = output_queue
|
||||
self.output = output
|
||||
if mixer_class is None:
|
||||
mixer_class = get_class(settings.MIXER)
|
||||
self.mixer = mixer_class(self)
|
||||
|
||||
@ -311,9 +311,10 @@ class BasePlaybackController(object):
|
||||
return
|
||||
|
||||
original_cp_track = self.current_cp_track
|
||||
if self.cp_track_at_eot:
|
||||
self.play(self.cp_track_at_eot)
|
||||
|
||||
if self.cp_track_at_eot:
|
||||
self._trigger_stopped_playing_event()
|
||||
self.play(self.cp_track_at_eot)
|
||||
if self.random and self.current_cp_track in self._shuffled:
|
||||
self._shuffled.remove(self.current_cp_track)
|
||||
else:
|
||||
@ -346,6 +347,7 @@ class BasePlaybackController(object):
|
||||
return
|
||||
|
||||
if self.cp_track_at_next:
|
||||
self._trigger_stopped_playing_event()
|
||||
self.play(self.cp_track_at_next)
|
||||
else:
|
||||
self.stop()
|
||||
@ -400,6 +402,8 @@ class BasePlaybackController(object):
|
||||
if self.random and self.current_cp_track in self._shuffled:
|
||||
self._shuffled.remove(self.current_cp_track)
|
||||
|
||||
self._trigger_started_playing_event()
|
||||
|
||||
def _play(self, track):
|
||||
"""
|
||||
To be overridden by subclass. Implement your backend's play
|
||||
@ -418,6 +422,7 @@ class BasePlaybackController(object):
|
||||
return
|
||||
if self.state == self.STOPPED:
|
||||
return
|
||||
self._trigger_stopped_playing_event()
|
||||
self.play(self.cp_track_at_previous, on_error_step=-1)
|
||||
|
||||
def resume(self):
|
||||
@ -442,8 +447,9 @@ class BasePlaybackController(object):
|
||||
:type time_position: int
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
# FIXME I think return value is only really useful for internal
|
||||
# testing, as such it should probably not be exposed in API.
|
||||
if not self.backend.current_playlist.tracks:
|
||||
return False
|
||||
|
||||
if self.state == self.STOPPED:
|
||||
self.play()
|
||||
elif self.state == self.PAUSED:
|
||||
@ -451,9 +457,9 @@ class BasePlaybackController(object):
|
||||
|
||||
if time_position < 0:
|
||||
time_position = 0
|
||||
elif self.current_track and time_position > self.current_track.length:
|
||||
elif time_position > self.current_track.length:
|
||||
self.next()
|
||||
return
|
||||
return True
|
||||
|
||||
self._play_time_started = self._current_wall_time
|
||||
self._play_time_accumulated = time_position
|
||||
@ -473,7 +479,10 @@ class BasePlaybackController(object):
|
||||
|
||||
def stop(self):
|
||||
"""Stop playing."""
|
||||
if self.state != self.STOPPED and self._stop():
|
||||
if self.state == self.STOPPED:
|
||||
return
|
||||
self._trigger_stopped_playing_event()
|
||||
if self._stop():
|
||||
self.state = self.STOPPED
|
||||
|
||||
def _stop(self):
|
||||
@ -484,3 +493,31 @@ class BasePlaybackController(object):
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _trigger_started_playing_event(self):
|
||||
"""
|
||||
Notifies frontends that a track has started playing.
|
||||
|
||||
For internal use only. Should be called by the backend directly after a
|
||||
track has started playing.
|
||||
"""
|
||||
self.backend.core_queue.put({
|
||||
'to': 'frontend',
|
||||
'command': 'started_playing',
|
||||
'track': self.current_track,
|
||||
})
|
||||
|
||||
def _trigger_stopped_playing_event(self):
|
||||
"""
|
||||
Notifies frontends that a track has stopped playing.
|
||||
|
||||
For internal use only. Should be called by the backend before a track
|
||||
is stopped playing, e.g. at the next, previous, and stop actions and at
|
||||
end-of-track.
|
||||
"""
|
||||
self.backend.core_queue.put({
|
||||
'to': 'frontend',
|
||||
'command': 'stopped_playing',
|
||||
'track': self.current_track,
|
||||
'stop_position': self.time_position,
|
||||
})
|
||||
|
||||
@ -59,11 +59,17 @@ class DummyPlaybackController(BasePlaybackController):
|
||||
return True
|
||||
|
||||
def _seek(self, time_position):
|
||||
pass
|
||||
return True
|
||||
|
||||
def _stop(self):
|
||||
return True
|
||||
|
||||
def _trigger_started_playing_event(self):
|
||||
pass # noop
|
||||
|
||||
def _trigger_stopped_playing_event(self):
|
||||
pass # noop
|
||||
|
||||
|
||||
class DummyStoredPlaylistsController(BaseStoredPlaylistsController):
|
||||
_playlists = []
|
||||
|
||||
@ -51,10 +51,10 @@ class LibspotifyBackend(BaseBackend):
|
||||
from .session_manager import LibspotifySessionManager
|
||||
|
||||
logger.info(u'Mopidy uses SPOTIFY(R) CORE')
|
||||
logger.info(u'Connecting to Spotify')
|
||||
logger.debug(u'Connecting to Spotify')
|
||||
spotify = LibspotifySessionManager(
|
||||
settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD,
|
||||
core_queue=self.core_queue,
|
||||
output_queue=self.output_queue)
|
||||
output=self.output)
|
||||
spotify.start()
|
||||
return spotify
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import multiprocessing
|
||||
|
||||
from spotify import Link
|
||||
from spotify import Link, SpotifyError
|
||||
|
||||
from mopidy.backends.base import BaseLibraryController
|
||||
from mopidy.backends.libspotify import ENCODING
|
||||
@ -14,10 +14,15 @@ class LibspotifyLibraryController(BaseLibraryController):
|
||||
return self.search(**query)
|
||||
|
||||
def lookup(self, uri):
|
||||
spotify_track = Link.from_string(uri).as_track()
|
||||
# TODO Block until metadata_updated callback is called. Before that the
|
||||
# track will be unloaded, unless it's already in the stored playlists.
|
||||
return LibspotifyTranslator.to_mopidy_track(spotify_track)
|
||||
try:
|
||||
spotify_track = Link.from_string(uri).as_track()
|
||||
# TODO Block until metadata_updated callback is called. Before that
|
||||
# the track will be unloaded, unless it's already in the stored
|
||||
# playlists.
|
||||
return LibspotifyTranslator.to_mopidy_track(spotify_track)
|
||||
except SpotifyError as e:
|
||||
logger.warning(u'Failed to lookup: %s', track.uri, e)
|
||||
return None
|
||||
|
||||
def refresh(self, uri=None):
|
||||
pass # TODO
|
||||
|
||||
@ -1,30 +1,17 @@
|
||||
import logging
|
||||
import multiprocessing
|
||||
|
||||
from spotify import Link, SpotifyError
|
||||
|
||||
from mopidy.backends.base import BasePlaybackController
|
||||
from mopidy.utils.process import pickle_connection
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.libspotify.playback')
|
||||
|
||||
class LibspotifyPlaybackController(BasePlaybackController):
|
||||
def _set_output_state(self, state_name):
|
||||
logger.debug(u'Setting output state to %s ...', state_name)
|
||||
(my_end, other_end) = multiprocessing.Pipe()
|
||||
self.backend.output_queue.put({
|
||||
'command': 'set_state',
|
||||
'state': state_name,
|
||||
'reply_to': pickle_connection(other_end),
|
||||
})
|
||||
my_end.poll(None)
|
||||
return my_end.recv()
|
||||
|
||||
def _pause(self):
|
||||
return self._set_output_state('PAUSED')
|
||||
return self.backend.output.set_state('PAUSED')
|
||||
|
||||
def _play(self, track):
|
||||
self._set_output_state('READY')
|
||||
self.backend.output.set_state('READY')
|
||||
if self.state == self.PLAYING:
|
||||
self.backend.spotify.session.play(0)
|
||||
if track.uri is None:
|
||||
@ -33,7 +20,7 @@ class LibspotifyPlaybackController(BasePlaybackController):
|
||||
self.backend.spotify.session.load(
|
||||
Link.from_string(track.uri).as_track())
|
||||
self.backend.spotify.session.play(1)
|
||||
self._set_output_state('PLAYING')
|
||||
self.backend.output.set_state('PLAYING')
|
||||
return True
|
||||
except SpotifyError as e:
|
||||
logger.warning('Play %s failed: %s', track.uri, e)
|
||||
@ -43,12 +30,12 @@ class LibspotifyPlaybackController(BasePlaybackController):
|
||||
return self._seek(self.time_position)
|
||||
|
||||
def _seek(self, time_position):
|
||||
self._set_output_state('READY')
|
||||
self.backend.output.set_state('READY')
|
||||
self.backend.spotify.session.seek(time_position)
|
||||
self._set_output_state('PLAYING')
|
||||
self.backend.output.set_state('PLAYING')
|
||||
return True
|
||||
|
||||
def _stop(self):
|
||||
result = self._set_output_state('READY')
|
||||
result = self.backend.output.set_state('READY')
|
||||
self.backend.spotify.session.play(0)
|
||||
return result
|
||||
|
||||
@ -17,15 +17,15 @@ class LibspotifySessionManager(SpotifySessionManager, BaseThread):
|
||||
appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key')
|
||||
user_agent = 'Mopidy %s' % get_version()
|
||||
|
||||
def __init__(self, username, password, core_queue, output_queue):
|
||||
def __init__(self, username, password, core_queue, output):
|
||||
SpotifySessionManager.__init__(self, username, password)
|
||||
BaseThread.__init__(self)
|
||||
self.name = 'LibspotifySessionManagerThread'
|
||||
self.name = 'LibspotifySMThread'
|
||||
# Run as a daemon thread, so Mopidy won't wait for this thread to exit
|
||||
# before Mopidy exits.
|
||||
self.daemon = True
|
||||
self.core_queue = core_queue
|
||||
self.output_queue = output_queue
|
||||
self.output = output
|
||||
self.connected = threading.Event()
|
||||
self.session = None
|
||||
|
||||
@ -34,17 +34,17 @@ class LibspotifySessionManager(SpotifySessionManager, BaseThread):
|
||||
|
||||
def logged_in(self, session, error):
|
||||
"""Callback used by pyspotify"""
|
||||
logger.info('Logged in')
|
||||
logger.info(u'Connected to Spotify')
|
||||
self.session = session
|
||||
self.connected.set()
|
||||
|
||||
def logged_out(self, session):
|
||||
"""Callback used by pyspotify"""
|
||||
logger.info('Logged out')
|
||||
logger.info(u'Disconnected from Spotify')
|
||||
|
||||
def metadata_updated(self, session):
|
||||
"""Callback used by pyspotify"""
|
||||
logger.debug('Metadata updated, refreshing stored playlists')
|
||||
logger.debug(u'Metadata updated, refreshing stored playlists')
|
||||
playlists = []
|
||||
for spotify_playlist in session.playlist_container():
|
||||
playlists.append(
|
||||
@ -56,21 +56,21 @@ class LibspotifySessionManager(SpotifySessionManager, BaseThread):
|
||||
|
||||
def connection_error(self, session, error):
|
||||
"""Callback used by pyspotify"""
|
||||
logger.error('Connection error: %s', error)
|
||||
logger.error(u'Connection error: %s', error)
|
||||
|
||||
def message_to_user(self, session, message):
|
||||
"""Callback used by pyspotify"""
|
||||
logger.info(message.strip())
|
||||
logger.debug(u'User message: %s', message.strip())
|
||||
|
||||
def notify_main_thread(self, session):
|
||||
"""Callback used by pyspotify"""
|
||||
logger.debug('Notify main thread')
|
||||
logger.debug(u'notify_main_thread() called')
|
||||
|
||||
def music_delivery(self, session, frames, frame_size, num_frames,
|
||||
sample_type, sample_rate, channels):
|
||||
"""Callback used by pyspotify"""
|
||||
# TODO Base caps_string on arguments
|
||||
caps_string = """
|
||||
capabilites = """
|
||||
audio/x-raw-int,
|
||||
endianness=(int)1234,
|
||||
channels=(int)2,
|
||||
@ -79,25 +79,21 @@ class LibspotifySessionManager(SpotifySessionManager, BaseThread):
|
||||
signed=True,
|
||||
rate=(int)44100
|
||||
"""
|
||||
self.output_queue.put({
|
||||
'command': 'deliver_data',
|
||||
'caps': caps_string,
|
||||
'data': bytes(frames),
|
||||
})
|
||||
self.output.deliver_data(capabilites, bytes(frames))
|
||||
|
||||
def play_token_lost(self, session):
|
||||
"""Callback used by pyspotify"""
|
||||
logger.debug('Play token lost')
|
||||
logger.debug(u'Play token lost')
|
||||
self.core_queue.put({'command': 'stop_playback'})
|
||||
|
||||
def log_message(self, session, data):
|
||||
"""Callback used by pyspotify"""
|
||||
logger.debug(data.strip())
|
||||
logger.debug(u'System message: %s' % data.strip())
|
||||
|
||||
def end_of_track(self, session):
|
||||
"""Callback used by pyspotify"""
|
||||
logger.debug('End of data stream.')
|
||||
self.output_queue.put({'command': 'end_of_data_stream'})
|
||||
logger.debug(u'End of data stream reached')
|
||||
self.output.end_of_data_stream()
|
||||
|
||||
def search(self, query, connection):
|
||||
"""Search method used by Mopidy backend"""
|
||||
|
||||
@ -41,38 +41,24 @@ class LocalPlaybackController(BasePlaybackController):
|
||||
super(LocalPlaybackController, self).__init__(backend)
|
||||
self.stop()
|
||||
|
||||
def _send_recv(self, message):
|
||||
(my_end, other_end) = multiprocessing.Pipe()
|
||||
message.update({'reply_to': pickle_connection(other_end)})
|
||||
self.backend.output_queue.put(message)
|
||||
my_end.poll(None)
|
||||
return my_end.recv()
|
||||
|
||||
def _send(self, message):
|
||||
self.backend.output_queue.put(message)
|
||||
|
||||
def _set_state(self, state):
|
||||
return self._send_recv({'command': 'set_state', 'state': state})
|
||||
|
||||
def _play(self, track):
|
||||
return self._send_recv({'command': 'play_uri', 'uri': track.uri})
|
||||
return self.backend.output.play_uri(track.uri)
|
||||
|
||||
def _stop(self):
|
||||
return self._set_state('READY')
|
||||
return self.backend.output.set_state('READY')
|
||||
|
||||
def _pause(self):
|
||||
return self._set_state('PAUSED')
|
||||
return self.backend.output.set_state('PAUSED')
|
||||
|
||||
def _resume(self):
|
||||
return self._set_state('PLAYING')
|
||||
return self.backend.output.set_state('PLAYING')
|
||||
|
||||
def _seek(self, time_position):
|
||||
return self._send_recv({'command': 'set_position',
|
||||
'position': time_position})
|
||||
return self.backend.output.set_position(time_position)
|
||||
|
||||
@property
|
||||
def time_position(self):
|
||||
return self._send_recv({'command': 'get_position'})
|
||||
return self.backend.output.get_position()
|
||||
|
||||
|
||||
class LocalStoredPlaylistsController(BaseStoredPlaylistsController):
|
||||
|
||||
@ -2,11 +2,11 @@ import logging
|
||||
import multiprocessing
|
||||
import optparse
|
||||
|
||||
from mopidy import get_version, settings
|
||||
from mopidy import get_version, settings, OptionalDependencyError
|
||||
from mopidy.utils import get_class
|
||||
from mopidy.utils.log import setup_logging
|
||||
from mopidy.utils.path import get_or_create_folder, get_or_create_file
|
||||
from mopidy.utils.process import BaseProcess, unpickle_connection
|
||||
from mopidy.utils.process import BaseProcess
|
||||
from mopidy.utils.settings import list_settings_optparse_callback
|
||||
|
||||
logger = logging.getLogger('mopidy.core')
|
||||
@ -16,9 +16,9 @@ class CoreProcess(BaseProcess):
|
||||
super(CoreProcess, self).__init__(name='CoreProcess')
|
||||
self.core_queue = multiprocessing.Queue()
|
||||
self.options = self.parse_options()
|
||||
self.output_queue = None
|
||||
self.output = None
|
||||
self.backend = None
|
||||
self.frontend = None
|
||||
self.frontends = []
|
||||
|
||||
def parse_options(self):
|
||||
parser = optparse.OptionParser(version='Mopidy %s' % get_version())
|
||||
@ -28,16 +28,15 @@ class CoreProcess(BaseProcess):
|
||||
parser.add_option('-v', '--verbose',
|
||||
action='store_const', const=2, dest='verbosity_level',
|
||||
help='more output (debug level)')
|
||||
parser.add_option('--dump',
|
||||
action='store_true', dest='dump',
|
||||
help='dump debug log to file')
|
||||
parser.add_option('--save-debug-log',
|
||||
action='store_true', dest='save_debug_log',
|
||||
help='save debug log to "./mopidy.log"')
|
||||
parser.add_option('--list-settings',
|
||||
action='callback', callback=list_settings_optparse_callback,
|
||||
help='list current settings')
|
||||
return parser.parse_args()[0]
|
||||
|
||||
def run_inside_try(self):
|
||||
logger.info(u'-- Starting Mopidy --')
|
||||
self.setup()
|
||||
while True:
|
||||
message = self.core_queue.get()
|
||||
@ -46,12 +45,14 @@ class CoreProcess(BaseProcess):
|
||||
def setup(self):
|
||||
self.setup_logging()
|
||||
self.setup_settings()
|
||||
self.output_queue = self.setup_output(self.core_queue)
|
||||
self.backend = self.setup_backend(self.core_queue, self.output_queue)
|
||||
self.frontend = self.setup_frontend(self.core_queue, self.backend)
|
||||
self.output = self.setup_output(self.core_queue)
|
||||
self.backend = self.setup_backend(self.core_queue, self.output)
|
||||
self.frontends = self.setup_frontends(self.core_queue, self.backend)
|
||||
|
||||
def setup_logging(self):
|
||||
setup_logging(self.options.verbosity_level, self.options.dump)
|
||||
setup_logging(self.options.verbosity_level,
|
||||
self.options.save_debug_log)
|
||||
logger.info(u'-- Starting Mopidy --')
|
||||
|
||||
def setup_settings(self):
|
||||
get_or_create_folder('~/.mopidy/')
|
||||
@ -59,27 +60,30 @@ class CoreProcess(BaseProcess):
|
||||
settings.validate()
|
||||
|
||||
def setup_output(self, core_queue):
|
||||
output_queue = multiprocessing.Queue()
|
||||
get_class(settings.OUTPUT)(core_queue, output_queue)
|
||||
return output_queue
|
||||
output = get_class(settings.OUTPUT)(core_queue)
|
||||
output.start()
|
||||
return output
|
||||
|
||||
def setup_backend(self, core_queue, output_queue):
|
||||
return get_class(settings.BACKENDS[0])(core_queue, output_queue)
|
||||
def setup_backend(self, core_queue, output):
|
||||
return get_class(settings.BACKENDS[0])(core_queue, output)
|
||||
|
||||
def setup_frontend(self, core_queue, backend):
|
||||
frontend = get_class(settings.FRONTENDS[0])()
|
||||
frontend.start_server(core_queue)
|
||||
frontend.create_dispatcher(backend)
|
||||
return frontend
|
||||
def setup_frontends(self, core_queue, backend):
|
||||
frontends = []
|
||||
for frontend_class_name in settings.FRONTENDS:
|
||||
try:
|
||||
frontend = get_class(frontend_class_name)(core_queue, backend)
|
||||
frontend.start()
|
||||
frontends.append(frontend)
|
||||
except OptionalDependencyError as e:
|
||||
logger.info(u'Disabled: %s (%s)', frontend_class_name, e)
|
||||
return frontends
|
||||
|
||||
def process_message(self, message):
|
||||
if message.get('to') == 'output':
|
||||
self.output_queue.put(message)
|
||||
elif message['command'] == 'mpd_request':
|
||||
response = self.frontend.dispatcher.handle_request(
|
||||
message['request'])
|
||||
connection = unpickle_connection(message['reply_to'])
|
||||
connection.send(response)
|
||||
self.output.process_message(message)
|
||||
elif message.get('to') == 'frontend':
|
||||
for frontend in self.frontends:
|
||||
frontend.process_message(message)
|
||||
elif message['command'] == 'end_of_track':
|
||||
self.backend.playback.on_end_of_track()
|
||||
elif message['command'] == 'stop_playback':
|
||||
|
||||
30
mopidy/frontends/base.py
Normal file
30
mopidy/frontends/base.py
Normal file
@ -0,0 +1,30 @@
|
||||
class BaseFrontend(object):
|
||||
"""
|
||||
Base class for frontends.
|
||||
|
||||
:param core_queue: queue for messaging the core
|
||||
:type core_queue: :class:`multiprocessing.Queue`
|
||||
:param backend: the backend
|
||||
:type backend: :class:`mopidy.backends.base.BaseBackend`
|
||||
"""
|
||||
|
||||
def __init__(self, core_queue, backend):
|
||||
self.core_queue = core_queue
|
||||
self.backend = backend
|
||||
|
||||
def start(self):
|
||||
"""Start the frontend."""
|
||||
pass
|
||||
|
||||
def destroy(self):
|
||||
"""Destroy the frontend."""
|
||||
pass
|
||||
|
||||
def process_message(self, message):
|
||||
"""
|
||||
Process messages for the frontend.
|
||||
|
||||
:param message: the message
|
||||
:type message: dict
|
||||
"""
|
||||
raise NotImplementedError
|
||||
140
mopidy/frontends/lastfm.py
Normal file
140
mopidy/frontends/lastfm.py
Normal file
@ -0,0 +1,140 @@
|
||||
import logging
|
||||
import multiprocessing
|
||||
import socket
|
||||
import time
|
||||
|
||||
try:
|
||||
import pylast
|
||||
except ImportError as e:
|
||||
from mopidy import OptionalDependencyError
|
||||
raise OptionalDependencyError(e)
|
||||
|
||||
from mopidy import get_version, settings, SettingsError
|
||||
from mopidy.frontends.base import BaseFrontend
|
||||
from mopidy.utils.process import BaseProcess
|
||||
|
||||
logger = logging.getLogger('mopidy.frontends.lastfm')
|
||||
|
||||
CLIENT_ID = u'mop'
|
||||
CLIENT_VERSION = get_version()
|
||||
|
||||
# pylast raises UnicodeEncodeError on conversion from unicode objects to
|
||||
# ascii-encoded bytestrings, so we explicitly encode as utf-8 before passing
|
||||
# strings to pylast.
|
||||
ENCODING = u'utf-8'
|
||||
|
||||
class LastfmFrontend(BaseFrontend):
|
||||
"""
|
||||
Frontend which scrobbles the music you play to your `Last.fm
|
||||
<http://www.last.fm>`_ profile.
|
||||
|
||||
.. note::
|
||||
|
||||
This frontend requires a free user account at Last.fm.
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- `pylast <http://code.google.com/p/pylast/>`_ >= 0.4.30
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.LASTFM_USERNAME`
|
||||
- :attr:`mopidy.settings.LASTFM_PASSWORD`
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LastfmFrontend, self).__init__(*args, **kwargs)
|
||||
(self.connection, other_end) = multiprocessing.Pipe()
|
||||
self.process = LastfmFrontendProcess(other_end)
|
||||
|
||||
def start(self):
|
||||
self.process.start()
|
||||
|
||||
def destroy(self):
|
||||
self.process.destroy()
|
||||
|
||||
def process_message(self, message):
|
||||
self.connection.send(message)
|
||||
|
||||
|
||||
class LastfmFrontendProcess(BaseProcess):
|
||||
def __init__(self, connection):
|
||||
super(LastfmFrontendProcess, self).__init__()
|
||||
self.name = u'LastfmFrontendProcess'
|
||||
self.daemon = True
|
||||
self.connection = connection
|
||||
self.lastfm = None
|
||||
self.scrobbler = None
|
||||
self.last_start_time = None
|
||||
|
||||
def run_inside_try(self):
|
||||
self.setup()
|
||||
while True:
|
||||
self.connection.poll(None)
|
||||
message = self.connection.recv()
|
||||
self.process_message(message)
|
||||
|
||||
def setup(self):
|
||||
try:
|
||||
username = settings.LASTFM_USERNAME
|
||||
password_hash = pylast.md5(settings.LASTFM_PASSWORD)
|
||||
self.lastfm = pylast.get_lastfm_network(
|
||||
username=username, password_hash=password_hash)
|
||||
self.scrobbler = self.lastfm.get_scrobbler(
|
||||
CLIENT_ID, CLIENT_VERSION)
|
||||
logger.info(u'Connected to Last.fm')
|
||||
except SettingsError as e:
|
||||
logger.info(u'Last.fm scrobbler did not start.')
|
||||
logger.debug(u'Last.fm settings error: %s', e)
|
||||
except (pylast.WSError, socket.error) as e:
|
||||
logger.error(u'Last.fm connection error: %s', e)
|
||||
|
||||
def process_message(self, message):
|
||||
if message['command'] == 'started_playing':
|
||||
self.started_playing(message['track'])
|
||||
elif message['command'] == 'stopped_playing':
|
||||
self.stopped_playing(message['track'], message['stop_position'])
|
||||
else:
|
||||
pass # Ignore commands for other frontends
|
||||
|
||||
def started_playing(self, track):
|
||||
artists = ', '.join([a.name for a in track.artists])
|
||||
duration = track.length // 1000
|
||||
self.last_start_time = int(time.time())
|
||||
logger.debug(u'Now playing track: %s - %s', artists, track.name)
|
||||
try:
|
||||
self.scrobbler.report_now_playing(
|
||||
artists.encode(ENCODING),
|
||||
track.name.encode(ENCODING),
|
||||
album=track.album.name.encode(ENCODING),
|
||||
duration=duration,
|
||||
track_number=track.track_no)
|
||||
except (pylast.ScrobblingError, socket.error) as e:
|
||||
logger.error(u'Last.fm now playing error: %s', e)
|
||||
|
||||
def stopped_playing(self, track, stop_position):
|
||||
artists = ', '.join([a.name for a in track.artists])
|
||||
duration = track.length // 1000
|
||||
stop_position = stop_position // 1000
|
||||
if duration < 30:
|
||||
logger.debug(u'Track too short to scrobble. (30s)')
|
||||
return
|
||||
if stop_position < duration // 2 and stop_position < 240:
|
||||
logger.debug(
|
||||
u'Track not played long enough to scrobble. (50% or 240s)')
|
||||
return
|
||||
if self.last_start_time is None:
|
||||
self.last_start_time = int(time.time()) - duration
|
||||
logger.debug(u'Scrobbling track: %s - %s', artists, track.name)
|
||||
try:
|
||||
self.scrobbler.scrobble(
|
||||
artists.encode(ENCODING),
|
||||
track.name.encode(ENCODING),
|
||||
time_started=self.last_start_time,
|
||||
source=pylast.SCROBBLE_SOURCE_USER,
|
||||
mode=pylast.SCROBBLE_MODE_PLAYED,
|
||||
duration=duration,
|
||||
album=track.album.name.encode(ENCODING),
|
||||
track_number=track.track_no)
|
||||
except (pylast.ScrobblingError, socket.error) as e:
|
||||
logger.error(u'Last.fm scrobbling error: %s', e)
|
||||
@ -1,7 +1,13 @@
|
||||
import logging
|
||||
|
||||
from mopidy.frontends.base import BaseFrontend
|
||||
from mopidy.frontends.mpd.dispatcher import MpdDispatcher
|
||||
from mopidy.frontends.mpd.thread import MpdThread
|
||||
from mopidy.utils.process import unpickle_connection
|
||||
|
||||
class MpdFrontend(object):
|
||||
logger = logging.getLogger('mopidy.frontends.mpd')
|
||||
|
||||
class MpdFrontend(BaseFrontend):
|
||||
"""
|
||||
The MPD frontend.
|
||||
|
||||
@ -11,27 +17,32 @@ class MpdFrontend(object):
|
||||
- :attr:`mopidy.settings.MPD_SERVER_PORT`
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.thred = None
|
||||
self.dispatcher = None
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MpdFrontend, self).__init__(*args, **kwargs)
|
||||
self.thread = None
|
||||
self.dispatcher = MpdDispatcher(self.backend)
|
||||
|
||||
def start_server(self, core_queue):
|
||||
"""
|
||||
Starts the MPD server.
|
||||
|
||||
:param core_queue: the core queue
|
||||
:type core_queue: :class:`multiprocessing.Queue`
|
||||
"""
|
||||
self.thread = MpdThread(core_queue)
|
||||
def start(self):
|
||||
"""Starts the MPD server."""
|
||||
self.thread = MpdThread(self.core_queue)
|
||||
self.thread.start()
|
||||
|
||||
def create_dispatcher(self, backend):
|
||||
"""
|
||||
Creates a dispatcher for MPD requests.
|
||||
def destroy(self):
|
||||
"""Destroys the MPD server."""
|
||||
self.thread.destroy()
|
||||
|
||||
:param backend: the backend
|
||||
:type backend: :class:`mopidy.backends.base.BaseBackend`
|
||||
:rtype: :class:`mopidy.frontends.mpd.dispatcher.MpdDispatcher`
|
||||
def process_message(self, message):
|
||||
"""
|
||||
self.dispatcher = MpdDispatcher(backend)
|
||||
return self.dispatcher
|
||||
Processes messages with the MPD frontend as destination.
|
||||
|
||||
:param message: the message
|
||||
:type message: dict
|
||||
"""
|
||||
assert message['to'] == 'frontend', \
|
||||
u'Message recipient must be "frontend".'
|
||||
if message['command'] == 'mpd_request':
|
||||
response = self.dispatcher.handle_request(message['request'])
|
||||
connection = unpickle_connection(message['reply_to'])
|
||||
connection.send(response)
|
||||
else:
|
||||
pass # Ignore messages for other frontends
|
||||
|
||||
@ -11,14 +11,19 @@ def add(frontend, uri):
|
||||
|
||||
Adds the file ``URI`` to the playlist (directories add recursively).
|
||||
``URI`` can also be a single file.
|
||||
|
||||
*Clarifications:*
|
||||
|
||||
- ``add ""`` should add all tracks in the library to the current playlist.
|
||||
"""
|
||||
if not uri:
|
||||
return
|
||||
for handler_prefix in frontend.backend.uri_handlers:
|
||||
if uri.startswith(handler_prefix):
|
||||
track = frontend.backend.library.lookup(uri)
|
||||
if track is not None:
|
||||
frontend.backend.current_playlist.add(track)
|
||||
return
|
||||
|
||||
raise MpdNoExistError(
|
||||
u'directory or file not found', command=u'add')
|
||||
|
||||
@ -36,7 +41,13 @@ def addid(frontend, uri, songpos=None):
|
||||
addid "foo.mp3"
|
||||
Id: 999
|
||||
OK
|
||||
|
||||
*Clarifications:*
|
||||
|
||||
- ``addid ""`` should return an error.
|
||||
"""
|
||||
if not uri:
|
||||
raise MpdNoExistError(u'No such song', command=u'addid')
|
||||
if songpos is not None:
|
||||
songpos = int(songpos)
|
||||
track = frontend.backend.library.lookup(uri)
|
||||
@ -44,7 +55,8 @@ def addid(frontend, uri, songpos=None):
|
||||
raise MpdNoExistError(u'No such song', command=u'addid')
|
||||
if songpos and songpos > len(frontend.backend.current_playlist.tracks):
|
||||
raise MpdArgError(u'Bad song index', command=u'addid')
|
||||
cp_track = frontend.backend.current_playlist.add(track, at_position=songpos)
|
||||
cp_track = frontend.backend.current_playlist.add(track,
|
||||
at_position=songpos)
|
||||
return ('Id', cp_track[0])
|
||||
|
||||
@handle_pattern(r'^delete "(?P<start>\d+):(?P<end>\d+)*"$')
|
||||
|
||||
@ -29,14 +29,15 @@ class MpdServer(asyncore.dispatcher):
|
||||
self.set_reuse_addr()
|
||||
hostname = self._format_hostname(settings.MPD_SERVER_HOSTNAME)
|
||||
port = settings.MPD_SERVER_PORT
|
||||
logger.debug(u'Binding to [%s]:%s', hostname, port)
|
||||
logger.debug(u'MPD server is binding to [%s]:%s', hostname, port)
|
||||
self.bind((hostname, port))
|
||||
self.listen(1)
|
||||
logger.info(u'MPD server running at [%s]:%s',
|
||||
self._format_hostname(settings.MPD_SERVER_HOSTNAME),
|
||||
settings.MPD_SERVER_PORT)
|
||||
except IOError, e:
|
||||
sys.exit('MPD server startup failed: %s' % e)
|
||||
logger.error('MPD server startup failed: %s' % e)
|
||||
sys.exit(1)
|
||||
|
||||
def handle_accept(self):
|
||||
"""Handle new client connection."""
|
||||
|
||||
@ -48,6 +48,7 @@ class MpdSession(asynchat.async_chat):
|
||||
"""Handle request by sending it to the MPD frontend."""
|
||||
my_end, other_end = multiprocessing.Pipe()
|
||||
self.core_queue.put({
|
||||
'to': 'frontend',
|
||||
'command': 'mpd_request',
|
||||
'request': request,
|
||||
'reply_to': pickle_connection(other_end),
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import multiprocessing
|
||||
|
||||
from mopidy.mixers import BaseMixer
|
||||
from mopidy.utils.process import pickle_connection
|
||||
|
||||
class GStreamerSoftwareMixer(BaseMixer):
|
||||
"""Mixer which uses GStreamer to control volume in software."""
|
||||
@ -10,16 +7,7 @@ class GStreamerSoftwareMixer(BaseMixer):
|
||||
super(GStreamerSoftwareMixer, self).__init__(*args, **kwargs)
|
||||
|
||||
def _get_volume(self):
|
||||
my_end, other_end = multiprocessing.Pipe()
|
||||
self.backend.output_queue.put({
|
||||
'command': 'get_volume',
|
||||
'reply_to': pickle_connection(other_end),
|
||||
})
|
||||
my_end.poll(None)
|
||||
return my_end.recv()
|
||||
return self.backend.output.get_volume()
|
||||
|
||||
def _set_volume(self, volume):
|
||||
self.backend.output_queue.put({
|
||||
'command': 'set_volume',
|
||||
'volume': volume,
|
||||
})
|
||||
self.backend.output.set_volume(volume)
|
||||
|
||||
88
mopidy/outputs/base.py
Normal file
88
mopidy/outputs/base.py
Normal file
@ -0,0 +1,88 @@
|
||||
class BaseOutput(object):
|
||||
"""
|
||||
Base class for audio outputs.
|
||||
"""
|
||||
|
||||
def __init__(self, core_queue):
|
||||
self.core_queue = core_queue
|
||||
|
||||
def start(self):
|
||||
"""Start the output."""
|
||||
pass
|
||||
|
||||
def destroy(self):
|
||||
"""Destroy the output."""
|
||||
pass
|
||||
|
||||
def process_message(self, message):
|
||||
"""Process messages with the output as destination."""
|
||||
raise NotImplementedError
|
||||
|
||||
def play_uri(self, uri):
|
||||
"""
|
||||
Play URI.
|
||||
|
||||
:param uri: the URI to play
|
||||
:type uri: string
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def deliver_data(self, capabilities, data):
|
||||
"""
|
||||
Deliver audio data to be played.
|
||||
|
||||
:param capabilities: a GStreamer capabilities string
|
||||
:type capabilities: string
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def end_of_data_stream(self):
|
||||
"""Signal that the last audio data has been delivered."""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_position(self):
|
||||
"""
|
||||
Get position in milliseconds.
|
||||
|
||||
:rtype: int
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def set_position(self, position):
|
||||
"""
|
||||
Set position in milliseconds.
|
||||
|
||||
:param position: the position in milliseconds
|
||||
:type volume: int
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def set_state(self, state):
|
||||
"""
|
||||
Set playback state.
|
||||
|
||||
:param state: the state
|
||||
:type state: string
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_volume(self):
|
||||
"""
|
||||
Get volume level for software mixer.
|
||||
|
||||
:rtype: int in range [0..100]
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def set_volume(self, volume):
|
||||
"""
|
||||
Set volume level for software mixer.
|
||||
|
||||
:param volume: the volume in the range [0..100]
|
||||
:type volume: int
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
76
mopidy/outputs/dummy.py
Normal file
76
mopidy/outputs/dummy.py
Normal file
@ -0,0 +1,76 @@
|
||||
from mopidy.outputs.base import BaseOutput
|
||||
|
||||
class DummyOutput(BaseOutput):
|
||||
"""
|
||||
Audio output used for testing.
|
||||
"""
|
||||
|
||||
#: For testing. :class:`True` if :meth:`start` has been called.
|
||||
start_called = False
|
||||
|
||||
#: For testing. :class:`True` if :meth:`destroy` has been called.
|
||||
destroy_called = False
|
||||
|
||||
#: For testing. Contains all messages :meth:`process_message` has received.
|
||||
messages = []
|
||||
|
||||
#: For testing. Contains the last URI passed to :meth:`play_uri`.
|
||||
uri = None
|
||||
|
||||
#: For testing. Contains the last capabilities passed to
|
||||
#: :meth:`deliver_data`.
|
||||
capabilities = None
|
||||
|
||||
#: For testing. Contains the last data passed to :meth:`deliver_data`.
|
||||
data = None
|
||||
|
||||
#: For testing. :class:`True` if :meth:`end_of_data_stream` has been
|
||||
#: called.
|
||||
end_of_data_stream_called = False
|
||||
|
||||
#: For testing. Contains the current position.
|
||||
position = 0
|
||||
|
||||
#: For testing. Contains the current state.
|
||||
state = 'NULL'
|
||||
|
||||
#: For testing. Contains the current volume.
|
||||
volume = 100
|
||||
|
||||
def start(self):
|
||||
self.start_called = True
|
||||
|
||||
def destroy(self):
|
||||
self.destroy_called = True
|
||||
|
||||
def process_message(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
def play_uri(self, uri):
|
||||
self.uri = uri
|
||||
return True
|
||||
|
||||
def deliver_data(self, capabilities, data):
|
||||
self.capabilities = capabilities
|
||||
self.data = data
|
||||
|
||||
def end_of_data_stream(self):
|
||||
self.end_of_data_stream_called = True
|
||||
|
||||
def get_position(self):
|
||||
return self.position
|
||||
|
||||
def set_position(self, position):
|
||||
self.position = position
|
||||
return True
|
||||
|
||||
def set_state(self, state):
|
||||
self.state = state
|
||||
return True
|
||||
|
||||
def get_volume(self):
|
||||
return self.volume
|
||||
|
||||
def set_volume(self, volume):
|
||||
self.volume = volume
|
||||
return True
|
||||
@ -6,14 +6,17 @@ pygst.require('0.10')
|
||||
import gst
|
||||
|
||||
import logging
|
||||
import multiprocessing
|
||||
import threading
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.utils.process import BaseThread, unpickle_connection
|
||||
from mopidy.outputs.base import BaseOutput
|
||||
from mopidy.utils.process import (BaseThread, pickle_connection,
|
||||
unpickle_connection)
|
||||
|
||||
logger = logging.getLogger('mopidy.outputs.gstreamer')
|
||||
|
||||
class GStreamerOutput(object):
|
||||
class GStreamerOutput(BaseOutput):
|
||||
"""
|
||||
Audio output through GStreamer.
|
||||
|
||||
@ -24,19 +27,70 @@ class GStreamerOutput(object):
|
||||
- :attr:`mopidy.settings.GSTREAMER_AUDIO_SINK`
|
||||
"""
|
||||
|
||||
def __init__(self, core_queue, output_queue):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(GStreamerOutput, self).__init__(*args, **kwargs)
|
||||
# Start a helper thread that can run the gobject.MainLoop
|
||||
self.messages_thread = GStreamerMessagesThread()
|
||||
self.messages_thread.start()
|
||||
|
||||
# Start a helper thread that can process the output_queue
|
||||
self.player_thread = GStreamerPlayerThread(core_queue, output_queue)
|
||||
self.output_queue = multiprocessing.Queue()
|
||||
self.player_thread = GStreamerPlayerThread(self.core_queue,
|
||||
self.output_queue)
|
||||
|
||||
def start(self):
|
||||
self.messages_thread.start()
|
||||
self.player_thread.start()
|
||||
|
||||
def destroy(self):
|
||||
self.messages_thread.destroy()
|
||||
self.player_thread.destroy()
|
||||
|
||||
def process_message(self, message):
|
||||
assert message['to'] == 'output', \
|
||||
u'Message recipient must be "output".'
|
||||
self.output_queue.put(message)
|
||||
|
||||
def _send_recv(self, message):
|
||||
(my_end, other_end) = multiprocessing.Pipe()
|
||||
message['to'] = 'output'
|
||||
message['reply_to'] = pickle_connection(other_end)
|
||||
self.process_message(message)
|
||||
my_end.poll(None)
|
||||
return my_end.recv()
|
||||
|
||||
def _send(self, message):
|
||||
message['to'] = 'output'
|
||||
self.process_message(message)
|
||||
|
||||
def play_uri(self, uri):
|
||||
return self._send_recv({'command': 'play_uri', 'uri': uri})
|
||||
|
||||
def deliver_data(self, capabilities, data):
|
||||
return self._send({
|
||||
'command': 'deliver_data',
|
||||
'caps': capabilities,
|
||||
'data': data,
|
||||
})
|
||||
|
||||
def end_of_data_stream(self):
|
||||
return self._send({'command': 'end_of_data_stream'})
|
||||
|
||||
def get_position(self):
|
||||
return self._send_recv({'command': 'get_position'})
|
||||
|
||||
def set_position(self, position):
|
||||
return self._send_recv({'command': 'set_position', 'position': position})
|
||||
|
||||
def set_state(self, state):
|
||||
return self._send_recv({'command': 'set_state', 'state': state})
|
||||
|
||||
def get_volume(self):
|
||||
return self._send_recv({'command': 'get_volume'})
|
||||
|
||||
def set_volume(self, volume):
|
||||
return self._send_recv({'command': 'set_volume', 'volume': volume})
|
||||
|
||||
|
||||
class GStreamerMessagesThread(BaseThread):
|
||||
def __init__(self):
|
||||
super(GStreamerMessagesThread, self).__init__()
|
||||
@ -46,6 +100,7 @@ class GStreamerMessagesThread(BaseThread):
|
||||
def run_inside_try(self):
|
||||
gobject.MainLoop().run()
|
||||
|
||||
|
||||
class GStreamerPlayerThread(BaseThread):
|
||||
"""
|
||||
A process for all work related to GStreamer.
|
||||
@ -119,7 +174,9 @@ class GStreamerPlayerThread(BaseThread):
|
||||
connection = unpickle_connection(message['reply_to'])
|
||||
connection.send(volume)
|
||||
elif message['command'] == 'set_volume':
|
||||
self.set_volume(message['volume'])
|
||||
response = self.set_volume(message['volume'])
|
||||
connection = unpickle_connection(message['reply_to'])
|
||||
connection.send(response)
|
||||
elif message['command'] == 'set_position':
|
||||
response = self.set_position(message['position'])
|
||||
connection = unpickle_connection(message['reply_to'])
|
||||
@ -203,6 +260,7 @@ class GStreamerPlayerThread(BaseThread):
|
||||
"""Set volume in range [0..100]"""
|
||||
gst_volume = self.gst_pipeline.get_by_name('volume')
|
||||
gst_volume.set_property('volume', volume / 100.0)
|
||||
return True
|
||||
|
||||
def set_position(self, position):
|
||||
self.gst_pipeline.get_state() # block until state changes are done
|
||||
|
||||
@ -20,36 +20,39 @@ BACKENDS = (
|
||||
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' + \
|
||||
#: The log format used for informational logging.
|
||||
#:
|
||||
#: See http://docs.python.org/library/logging.html#formatter-objects for
|
||||
#: details on the format.
|
||||
CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s'
|
||||
|
||||
#: The log format used for debug logging.
|
||||
#:
|
||||
#: See http://docs.python.org/library/logging.html#formatter-objects for
|
||||
#: details on the format.
|
||||
DEBUG_LOG_FORMAT = u'%(levelname)-8s %(asctime)s' + \
|
||||
' [%(process)d:%(threadName)s] %(name)s\n %(message)s'
|
||||
|
||||
#: The log format used for dump logs.
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: DUMP_LOG_FILENAME = CONSOLE_LOG_FORMAT
|
||||
DUMP_LOG_FORMAT = CONSOLE_LOG_FORMAT
|
||||
|
||||
#: The file to dump debug log data to when Mopidy is run with the
|
||||
#: :option:`--dump` option.
|
||||
#: :option:`--save-debug-log` option.
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: DUMP_LOG_FILENAME = u'dump.log'
|
||||
DUMP_LOG_FILENAME = u'dump.log'
|
||||
#: DEBUG_LOG_FILENAME = u'mopidy.log'
|
||||
DEBUG_LOG_FILENAME = u'mopidy.log'
|
||||
|
||||
#: List of server frontends to use.
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',)
|
||||
#:
|
||||
#: .. note::
|
||||
#: Currently only the first frontend in the list is used.
|
||||
FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',)
|
||||
#: FRONTENDS = (
|
||||
#: u'mopidy.frontends.mpd.MpdFrontend',
|
||||
#: u'mopidy.frontends.lastfm.LastfmFrontend',
|
||||
#: )
|
||||
FRONTENDS = (
|
||||
u'mopidy.frontends.mpd.MpdFrontend',
|
||||
u'mopidy.frontends.lastfm.LastfmFrontend',
|
||||
)
|
||||
|
||||
#: Which GStreamer audio sink to use in :mod:`mopidy.outputs.gstreamer`.
|
||||
#:
|
||||
@ -58,6 +61,16 @@ FRONTENDS = (u'mopidy.frontends.mpd.MpdFrontend',)
|
||||
#: GSTREAMER_AUDIO_SINK = u'autoaudiosink'
|
||||
GSTREAMER_AUDIO_SINK = u'autoaudiosink'
|
||||
|
||||
#: Your `Last.fm <http://www.last.fm/>`_ username.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.frontends.lastfm`.
|
||||
LASTFM_USERNAME = u''
|
||||
|
||||
#: Your `Last.fm <http://www.last.fm/>`_ password.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.frontends.lastfm`.
|
||||
LASTFM_PASSWORD = u''
|
||||
|
||||
#: Path to folder with local music.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.backends.local`.
|
||||
|
||||
@ -3,27 +3,40 @@ import logging.handlers
|
||||
|
||||
from mopidy import settings
|
||||
|
||||
def setup_logging(verbosity_level, dump):
|
||||
def setup_logging(verbosity_level, save_debug_log):
|
||||
setup_root_logger()
|
||||
setup_console_logging(verbosity_level)
|
||||
if dump:
|
||||
setup_dump_logging()
|
||||
if save_debug_log:
|
||||
setup_debug_logging_to_file()
|
||||
|
||||
def setup_root_logger():
|
||||
root = logging.getLogger('')
|
||||
root.setLevel(logging.DEBUG)
|
||||
|
||||
def setup_console_logging(verbosity_level):
|
||||
if verbosity_level == 0:
|
||||
level = logging.WARNING
|
||||
log_level = logging.WARNING
|
||||
log_format = settings.CONSOLE_LOG_FORMAT
|
||||
elif verbosity_level == 2:
|
||||
level = logging.DEBUG
|
||||
log_level = logging.DEBUG
|
||||
log_format = settings.DEBUG_LOG_FORMAT
|
||||
else:
|
||||
level = logging.INFO
|
||||
logging.basicConfig(format=settings.CONSOLE_LOG_FORMAT, level=level)
|
||||
|
||||
def setup_dump_logging():
|
||||
root = logging.getLogger('')
|
||||
root.setLevel(logging.DEBUG)
|
||||
formatter = logging.Formatter(settings.DUMP_LOG_FORMAT)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
settings.DUMP_LOG_FILENAME, maxBytes=102400, backupCount=3)
|
||||
log_level = logging.INFO
|
||||
log_format = settings.CONSOLE_LOG_FORMAT
|
||||
formatter = logging.Formatter(log_format)
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(formatter)
|
||||
handler.setLevel(log_level)
|
||||
root = logging.getLogger('')
|
||||
root.addHandler(handler)
|
||||
|
||||
def setup_debug_logging_to_file():
|
||||
formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
settings.DEBUG_LOG_FILENAME, maxBytes=10485760, backupCount=3)
|
||||
handler.setFormatter(formatter)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
root = logging.getLogger('')
|
||||
root.addHandler(handler)
|
||||
|
||||
def indent(string, places=4, linebreak='\n'):
|
||||
|
||||
@ -15,6 +15,7 @@ class SettingsProxy(object):
|
||||
self.default = self._get_settings_dict_from_module(
|
||||
default_settings_module)
|
||||
self.local = self._get_local_settings()
|
||||
self.runtime = {}
|
||||
|
||||
def _get_local_settings(self):
|
||||
dotdir = os.path.expanduser(u'~/.mopidy/')
|
||||
@ -37,6 +38,7 @@ class SettingsProxy(object):
|
||||
def current(self):
|
||||
current = copy(self.default)
|
||||
current.update(self.local)
|
||||
current.update(self.runtime)
|
||||
return current
|
||||
|
||||
def __getattr__(self, attr):
|
||||
@ -49,6 +51,12 @@ class SettingsProxy(object):
|
||||
raise SettingsError(u'Setting "%s" is empty.' % attr)
|
||||
return value
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
if self._is_setting(attr):
|
||||
self.runtime[attr] = value
|
||||
else:
|
||||
super(SettingsProxy, self).__setattr__(attr, value)
|
||||
|
||||
def validate(self):
|
||||
if self.get_errors():
|
||||
logger.error(u'Settings validation errors: %s',
|
||||
@ -81,6 +89,8 @@ def validate_settings(defaults, settings):
|
||||
errors = {}
|
||||
|
||||
changed = {
|
||||
'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME',
|
||||
'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT',
|
||||
'FRONTEND': 'FRONTENDS',
|
||||
'SERVER': None,
|
||||
'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME',
|
||||
@ -122,7 +132,7 @@ def list_settings_optparse_callback(*args):
|
||||
lines = []
|
||||
for (key, value) in sorted(settings.current.iteritems()):
|
||||
default_value = settings.default.get(key)
|
||||
if key.endswith('PASSWORD'):
|
||||
if key.endswith('PASSWORD') and len(value):
|
||||
value = u'********'
|
||||
lines.append(u'%s:' % key)
|
||||
lines.append(u' Value: %s' % repr(value))
|
||||
|
||||
1
requirements-lastfm.txt
Normal file
1
requirements-lastfm.txt
Normal file
@ -0,0 +1 @@
|
||||
pylast >= 0.4.30
|
||||
@ -4,6 +4,7 @@ import random
|
||||
from mopidy import settings
|
||||
from mopidy.mixers.dummy import DummyMixer
|
||||
from mopidy.models import Playlist, Track
|
||||
from mopidy.outputs.dummy import DummyOutput
|
||||
from mopidy.utils import get_class
|
||||
|
||||
from tests.backends.base import populate_playlist
|
||||
@ -12,12 +13,10 @@ class BaseCurrentPlaylistControllerTest(object):
|
||||
tracks = []
|
||||
|
||||
def setUp(self):
|
||||
self.output_queue = multiprocessing.Queue()
|
||||
self.core_queue = multiprocessing.Queue()
|
||||
self.output = get_class(settings.OUTPUT)(
|
||||
self.core_queue, self.output_queue)
|
||||
self.output = DummyOutput(self.core_queue)
|
||||
self.backend = self.backend_class(
|
||||
self.core_queue, self.output_queue, DummyMixer)
|
||||
self.core_queue, self.output, DummyMixer)
|
||||
self.controller = self.backend.current_playlist
|
||||
self.playback = self.backend.playback
|
||||
|
||||
|
||||
@ -5,21 +5,22 @@ import time
|
||||
from mopidy import settings
|
||||
from mopidy.mixers.dummy import DummyMixer
|
||||
from mopidy.models import Track
|
||||
from mopidy.outputs.dummy import DummyOutput
|
||||
from mopidy.utils import get_class
|
||||
|
||||
from tests import SkipTest
|
||||
from tests.backends.base import populate_playlist
|
||||
|
||||
# TODO Test 'playlist repeat', e.g. repeat=1,single=0
|
||||
|
||||
class BasePlaybackControllerTest(object):
|
||||
tracks = []
|
||||
|
||||
def setUp(self):
|
||||
self.output_queue = multiprocessing.Queue()
|
||||
self.core_queue = multiprocessing.Queue()
|
||||
self.output = get_class(settings.OUTPUT)(
|
||||
self.core_queue, self.output_queue)
|
||||
self.output = DummyOutput(self.core_queue)
|
||||
self.backend = self.backend_class(
|
||||
self.core_queue, self.output_queue, DummyMixer)
|
||||
self.core_queue, self.output, DummyMixer)
|
||||
self.playback = self.backend.playback
|
||||
self.current_playlist = self.backend.current_playlist
|
||||
|
||||
@ -527,12 +528,13 @@ class BasePlaybackControllerTest(object):
|
||||
|
||||
self.assert_(wrapper.called)
|
||||
|
||||
@SkipTest # Blocks for 10ms and does not work with DummyOutput
|
||||
@populate_playlist
|
||||
def test_end_of_track_callback_gets_called(self):
|
||||
self.playback.play()
|
||||
result = self.playback.seek(self.tracks[0].length - 10)
|
||||
self.assert_(result, 'Seek failed')
|
||||
message = self.core_queue.get()
|
||||
self.assertTrue(result, 'Seek failed')
|
||||
message = self.core_queue.get(True, 1)
|
||||
self.assertEqual('end_of_track', message['command'])
|
||||
|
||||
@populate_playlist
|
||||
@ -606,6 +608,7 @@ class BasePlaybackControllerTest(object):
|
||||
self.playback.pause()
|
||||
self.assertEqual(self.playback.resume(), None)
|
||||
|
||||
@SkipTest # Uses sleep and does not work with DummyOutput+LocalBackend
|
||||
@populate_playlist
|
||||
def test_resume_continues_from_right_position(self):
|
||||
self.playback.play()
|
||||
@ -626,8 +629,7 @@ class BasePlaybackControllerTest(object):
|
||||
self.assert_(position >= 990, position)
|
||||
|
||||
def test_seek_on_empty_playlist(self):
|
||||
result = self.playback.seek(0)
|
||||
self.assert_(not result, 'Seek return value was %s' % result)
|
||||
self.assertFalse(self.playback.seek(0))
|
||||
|
||||
def test_seek_on_empty_playlist_updates_position(self):
|
||||
self.playback.seek(0)
|
||||
@ -738,15 +740,16 @@ class BasePlaybackControllerTest(object):
|
||||
def test_time_position_when_stopped_with_playlist(self):
|
||||
self.assertEqual(self.playback.time_position, 0)
|
||||
|
||||
@SkipTest # Uses sleep and does not work with LocalBackend+DummyOutput
|
||||
@populate_playlist
|
||||
def test_time_position_when_playing(self):
|
||||
self.playback.play()
|
||||
first = self.playback.time_position
|
||||
time.sleep(1)
|
||||
second = self.playback.time_position
|
||||
|
||||
self.assert_(second > first, '%s - %s' % (first, second))
|
||||
|
||||
@SkipTest # Uses sleep
|
||||
@populate_playlist
|
||||
def test_time_position_when_paused(self):
|
||||
self.playback.play()
|
||||
@ -755,7 +758,6 @@ class BasePlaybackControllerTest(object):
|
||||
time.sleep(0.2)
|
||||
first = self.playback.time_position
|
||||
second = self.playback.time_position
|
||||
|
||||
self.assertEqual(first, second)
|
||||
|
||||
@populate_playlist
|
||||
|
||||
@ -10,10 +10,6 @@ from tests import SkipTest, data_folder
|
||||
|
||||
class BaseStoredPlaylistsControllerTest(object):
|
||||
def setUp(self):
|
||||
self.original_playlist_folder = settings.LOCAL_PLAYLIST_FOLDER
|
||||
self.original_tag_cache = settings.LOCAL_TAG_CACHE
|
||||
self.original_music_folder = settings.LOCAL_MUSIC_FOLDER
|
||||
|
||||
settings.LOCAL_PLAYLIST_FOLDER = tempfile.mkdtemp()
|
||||
settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache')
|
||||
settings.LOCAL_MUSIC_FOLDER = data_folder('')
|
||||
@ -27,9 +23,7 @@ class BaseStoredPlaylistsControllerTest(object):
|
||||
if os.path.exists(settings.LOCAL_PLAYLIST_FOLDER):
|
||||
shutil.rmtree(settings.LOCAL_PLAYLIST_FOLDER)
|
||||
|
||||
settings.LOCAL_PLAYLIST_FOLDER = self.original_playlist_folder
|
||||
settings.LOCAL_TAG_CACHE = self.original_tag_cache
|
||||
settings.LOCAL_MUSIC_FOLDER = self.original_music_folder
|
||||
settings.runtime.clear()
|
||||
|
||||
def test_create(self):
|
||||
playlist = self.stored.create('test')
|
||||
|
||||
@ -22,10 +22,9 @@ class LocalCurrentPlaylistControllerTest(BaseCurrentPlaylistControllerTest,
|
||||
for i in range(1, 4)]
|
||||
|
||||
def setUp(self):
|
||||
self.original_backends = settings.BACKENDS
|
||||
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
|
||||
super(LocalCurrentPlaylistControllerTest, self).setUp()
|
||||
|
||||
def tearDown(self):
|
||||
super(LocalCurrentPlaylistControllerTest, self).tearDown()
|
||||
settings.BACKENDS = settings.original_backends
|
||||
settings.runtime.clear()
|
||||
|
||||
@ -17,16 +17,12 @@ class LocalLibraryControllerTest(BaseLibraryControllerTest, unittest.TestCase):
|
||||
backend_class = LocalBackend
|
||||
|
||||
def setUp(self):
|
||||
self.original_tag_cache = settings.LOCAL_TAG_CACHE
|
||||
self.original_music_folder = settings.LOCAL_MUSIC_FOLDER
|
||||
|
||||
settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache')
|
||||
settings.LOCAL_MUSIC_FOLDER = data_folder('')
|
||||
|
||||
super(LocalLibraryControllerTest, self).setUp()
|
||||
|
||||
def tearDown(self):
|
||||
settings.LOCAL_TAG_CACHE = self.original_tag_cache
|
||||
settings.LOCAL_MUSIC_FOLDER = self.original_music_folder
|
||||
settings.runtime.clear()
|
||||
|
||||
super(LocalLibraryControllerTest, self).tearDown()
|
||||
|
||||
@ -23,7 +23,6 @@ class LocalPlaybackControllerTest(BasePlaybackControllerTest,
|
||||
for i in range(1, 4)]
|
||||
|
||||
def setUp(self):
|
||||
self.original_backends = settings.BACKENDS
|
||||
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
|
||||
|
||||
super(LocalPlaybackControllerTest, self).setUp()
|
||||
@ -32,7 +31,7 @@ class LocalPlaybackControllerTest(BasePlaybackControllerTest,
|
||||
|
||||
def tearDown(self):
|
||||
super(LocalPlaybackControllerTest, self).tearDown()
|
||||
settings.BACKENDS = settings.original_backends
|
||||
settings.runtime.clear()
|
||||
|
||||
def add_track(self, path):
|
||||
uri = path_to_uri(data_folder(path))
|
||||
|
||||
@ -33,6 +33,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
|
||||
self.assertEqual(result[0],
|
||||
u'ACK [50@0] {add} directory or file not found')
|
||||
|
||||
def test_add_with_empty_uri_should_add_all_known_tracks_and_ok(self):
|
||||
result = self.h.handle_request(u'add ""')
|
||||
# TODO check that we add all tracks (we currently don't)
|
||||
self.assert_(u'OK' in result)
|
||||
|
||||
def test_addid_without_songpos(self):
|
||||
needle = Track(uri='dummy://foo')
|
||||
self.b.library._library = [Track(), Track(), needle, Track()]
|
||||
@ -46,6 +51,11 @@ class CurrentPlaylistHandlerTest(unittest.TestCase):
|
||||
in result)
|
||||
self.assert_(u'OK' in result)
|
||||
|
||||
def test_addid_with_empty_uri_does_not_lookup_and_acks(self):
|
||||
self.b.library.lookup = lambda uri: self.fail("Shouldn't run")
|
||||
result = self.h.handle_request(u'addid ""')
|
||||
self.assertEqual(result[0], u'ACK [50@0] {addid} No such song')
|
||||
|
||||
def test_addid_with_songpos(self):
|
||||
needle = Track(uri='dummy://foo')
|
||||
self.b.library._library = [Track(), Track(), needle, Track()]
|
||||
|
||||
@ -1,61 +1,65 @@
|
||||
import multiprocessing
|
||||
import unittest
|
||||
|
||||
from tests import SkipTest
|
||||
|
||||
# FIXME Our Windows build server does not support GStreamer yet
|
||||
import sys
|
||||
if sys.platform == 'win32':
|
||||
raise SkipTest
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.outputs.gstreamer import GStreamerOutput
|
||||
from mopidy.utils.path import path_to_uri
|
||||
from mopidy.utils.process import pickle_connection
|
||||
|
||||
from tests import data_folder, SkipTest
|
||||
from tests import data_folder
|
||||
|
||||
class GStreamerOutputTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.original_backends = settings.BACKENDS
|
||||
settings.BACKENDS = ('mopidy.backends.local.LocalBackend',)
|
||||
self.song_uri = path_to_uri(data_folder('song1.wav'))
|
||||
self.output_queue = multiprocessing.Queue()
|
||||
self.core_queue = multiprocessing.Queue()
|
||||
self.output = GStreamerOutput(self.core_queue, self.output_queue)
|
||||
self.output = GStreamerOutput(self.core_queue)
|
||||
self.output.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.output.destroy()
|
||||
settings.BACKENDS = settings.original_backends
|
||||
|
||||
def send_recv(self, message):
|
||||
(my_end, other_end) = multiprocessing.Pipe()
|
||||
message.update({'reply_to': pickle_connection(other_end)})
|
||||
self.output_queue.put(message)
|
||||
my_end.poll(None)
|
||||
return my_end.recv()
|
||||
|
||||
|
||||
def send(self, message):
|
||||
self.output_queue.put(message)
|
||||
settings.runtime.clear()
|
||||
|
||||
def test_play_uri_existing_file(self):
|
||||
message = {'command': 'play_uri', 'uri': self.song_uri}
|
||||
self.assertEqual(True, self.send_recv(message))
|
||||
self.assertTrue(self.output.play_uri(self.song_uri))
|
||||
|
||||
def test_play_uri_non_existing_file(self):
|
||||
message = {'command': 'play_uri', 'uri': self.song_uri + 'bogus'}
|
||||
self.assertEqual(False, self.send_recv(message))
|
||||
self.assertFalse(self.output.play_uri(self.song_uri + 'bogus'))
|
||||
|
||||
@SkipTest
|
||||
def test_deliver_data(self):
|
||||
pass # TODO
|
||||
|
||||
@SkipTest
|
||||
def test_end_of_data_stream(self):
|
||||
pass # TODO
|
||||
|
||||
def test_default_get_volume_result(self):
|
||||
message = {'command': 'get_volume'}
|
||||
self.assertEqual(100, self.send_recv(message))
|
||||
self.assertEqual(100, self.output.get_volume())
|
||||
|
||||
def test_set_volume(self):
|
||||
self.send({'command': 'set_volume', 'volume': 50})
|
||||
self.assertEqual(50, self.send_recv({'command': 'get_volume'}))
|
||||
self.assertTrue(self.output.set_volume(50))
|
||||
self.assertEqual(50, self.output.get_volume())
|
||||
|
||||
def test_set_volume_to_zero(self):
|
||||
self.send({'command': 'set_volume', 'volume': 0})
|
||||
self.assertEqual(0, self.send_recv({'command': 'get_volume'}))
|
||||
self.assertTrue(self.output.set_volume(0))
|
||||
self.assertEqual(0, self.output.get_volume())
|
||||
|
||||
def test_set_volume_to_one_hundred(self):
|
||||
self.send({'command': 'set_volume', 'volume': 100})
|
||||
self.assertEqual(100, self.send_recv({'command': 'get_volume'}))
|
||||
self.assertTrue(self.output.set_volume(100))
|
||||
self.assertEqual(100, self.output.get_volume())
|
||||
|
||||
@SkipTest
|
||||
def test_set_state(self):
|
||||
raise NotImplementedError
|
||||
pass # TODO
|
||||
|
||||
@SkipTest
|
||||
def test_set_position(self):
|
||||
pass # TODO
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import unittest
|
||||
|
||||
from mopidy.utils.settings import validate_settings
|
||||
from mopidy import settings as default_settings_module
|
||||
from mopidy.utils.settings import validate_settings, SettingsProxy
|
||||
|
||||
class ValidateSettingsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@ -43,3 +44,24 @@ class ValidateSettingsTest(unittest.TestCase):
|
||||
result = validate_settings(self.defaults,
|
||||
{'FOO': '', 'BAR': ''})
|
||||
self.assertEquals(len(result), 2)
|
||||
|
||||
|
||||
class SettingsProxyTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.settings = SettingsProxy(default_settings_module)
|
||||
|
||||
def test_set_and_get_attr(self):
|
||||
self.settings.TEST = 'test'
|
||||
self.assertEqual(self.settings.TEST, 'test')
|
||||
|
||||
def test_setattr_updates_runtime_settings(self):
|
||||
self.settings.TEST = 'test'
|
||||
self.assert_('TEST' in self.settings.runtime)
|
||||
|
||||
def test_setattr_updates_runtime_with_value(self):
|
||||
self.settings.TEST = 'test'
|
||||
self.assertEqual(self.settings.runtime['TEST'], 'test')
|
||||
|
||||
def test_runtime_value_included_in_current(self):
|
||||
self.settings.TEST = 'test'
|
||||
self.assertEqual(self.settings.current['TEST'], 'test')
|
||||
|
||||
@ -11,7 +11,8 @@ class VersionTest(unittest.TestCase):
|
||||
self.assert_(SV('0.1.0a0') < SV('0.1.0a1'))
|
||||
self.assert_(SV('0.1.0a1') < SV('0.1.0a2'))
|
||||
self.assert_(SV('0.1.0a2') < SV('0.1.0a3'))
|
||||
self.assert_(SV('0.1.0a3') < SV(get_version()))
|
||||
self.assert_(SV(get_version()) < SV('0.1.1'))
|
||||
self.assert_(SV('0.1.0a3') < SV('0.1.0'))
|
||||
self.assert_(SV('0.1.0') < SV(get_version()))
|
||||
self.assert_(SV(get_version()) < SV('0.2.0'))
|
||||
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