Merge develop into feature/threads-not-processes

This commit is contained in:
Stein Magnus Jodal 2010-08-26 18:51:22 +02:00
commit 318524be21
45 changed files with 993 additions and 323 deletions

1
.gitignore vendored
View File

@ -8,4 +8,5 @@ cover/
coverage.xml
dist/
docs/_build/
mopidy.log
nosetests.xml

View File

@ -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`

View File

@ -0,0 +1,7 @@
******************************
:mod:`mopidy.frontends.lastfm`
******************************
.. automodule:: mopidy.frontends.lastfm
:synopsis: Last.fm scrobbler frontend
:members:

View File

@ -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
View File

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

98
docs/clients/mpd.rst Normal file
View 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.

View File

@ -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.

View File

@ -7,6 +7,9 @@ User documentation
:maxdepth: 3
installation/index
settings
running
clients/index
changes
authors
licenses

View File

@ -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
View 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
View 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'

View File

@ -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)

View File

@ -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)

View File

@ -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,
})

View File

@ -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 = []

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"""

View File

@ -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):

View File

@ -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
View 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
View 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)

View File

@ -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

View File

@ -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+)*"$')

View File

@ -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."""

View File

@ -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),

View File

@ -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
View 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
View 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

View File

@ -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

View File

@ -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`.

View File

@ -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'):

View File

@ -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
View File

@ -0,0 +1 @@
pylast >= 0.4.30

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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()

View File

@ -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()

View File

@ -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))

View File

@ -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()]

View File

@ -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

View File

@ -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')

View File

@ -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'))