Merge branch 'develop' into feature/mopidy.js

This commit is contained in:
Stein Magnus Jodal 2012-12-01 11:36:19 +01:00
commit e51fbd19fd
17 changed files with 217 additions and 172 deletions

View File

@ -19,7 +19,20 @@ class AudioListener(object):
"""Helper to allow calling of audio listener events"""
listeners = pykka.ActorRegistry.get_by_class(AudioListener)
for listener in listeners:
getattr(listener.proxy(), event)(**kwargs)
listener.proxy().on_event(event, **kwargs)
def on_event(self, event, **kwargs):
"""
Called on all events.
*MAY* be implemented by actor. By default, this method forwards the
event to the specific event methods.
:param event: the event name
:type event: string
:param kwargs: any other arguments to the specific event handlers
"""
getattr(self, event)(**kwargs)
def reached_end_of_stream(self):
"""

View File

@ -21,7 +21,20 @@ class BackendListener(object):
"""Helper to allow calling of backend listener events"""
listeners = pykka.ActorRegistry.get_by_class(BackendListener)
for listener in listeners:
getattr(listener.proxy(), event)(**kwargs)
listener.proxy().on_event(event, **kwargs)
def on_event(self, event, **kwargs):
"""
Called on all events.
*MAY* be implemented by actor. By default, this method forwards the
event to the specific event methods.
:param event: the event name
:type event: string
:param kwargs: any other arguments to the specific event handlers
"""
getattr(self, event)(**kwargs)
def playlists_loaded(self):
"""

View File

@ -57,7 +57,7 @@ class LocalPlaylistsProvider(base.BasePlaylistsProvider):
# from other backends
tracks += self.backend.library.lookup(track_uri)
except LookupError as ex:
logger.error('Playlist item could not be added: %s', ex)
logger.warning('Playlist item could not be added: %s', ex)
playlist = Playlist(uri=uri, name=name, tracks=tracks)
playlists.append(playlist)

View File

@ -35,7 +35,7 @@ def parse_m3u(file_path, music_folder):
with open(file_path) as m3u:
contents = m3u.readlines()
except IOError as error:
logger.error('Couldn\'t open m3u: %s', locale_decode(error))
logger.warning('Couldn\'t open m3u: %s', locale_decode(error))
return uris
for line in contents:
@ -64,7 +64,7 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''):
with open(tag_cache) as library:
contents = library.read()
except IOError as error:
logger.error('Could not open tag cache: %s', locale_decode(error))
logger.warning('Could not open tag cache: %s', locale_decode(error))
return tracks
current = {}

View File

@ -142,8 +142,9 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager):
# startup until the Spotify backend is ready from 35s to 12s in one
# test with clean Spotify cache. In cases with an outdated cache
# the time improvements should be a lot greater.
self._initial_data_receive_completed = True
self.refresh_playlists()
if not self._initial_data_receive_completed:
self._initial_data_receive_completed = True
self.refresh_playlists()
def end_of_track(self, session):
"""Callback used by pyspotify"""

View File

@ -19,7 +19,20 @@ class CoreListener(object):
"""Helper to allow calling of core listener events"""
listeners = pykka.ActorRegistry.get_by_class(CoreListener)
for listener in listeners:
getattr(listener.proxy(), event)(**kwargs)
listener.proxy().on_event(event, **kwargs)
def on_event(self, event, **kwargs):
"""
Called on all events.
*MAY* be implemented by actor. By default, this method forwards the
event to the specific event methods.
:param event: the event name
:type event: string
:param kwargs: any other arguments to the specific event handlers
"""
getattr(self, event)(**kwargs)
def track_playback_paused(self, track, time_position):
"""

View File

@ -53,6 +53,9 @@ class PlaybackController(object):
Tracks are not removed from the playlist.
"""
def get_current_tl_track(self):
return self.current_tl_track
current_tl_track = None
"""
The currently playing or selected :class:`mopidy.models.TlTrack`, or

View File

@ -1,5 +1,6 @@
"""
Frontend which lets you control Mopidy through HTTP and WebSockets.
The HTTP frontends lets you control Mopidy through HTTP and WebSockets, e.g.
from a web based client.
**Dependencies**
@ -15,36 +16,72 @@ Frontend which lets you control Mopidy through HTTP and WebSockets.
- :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR`
**Usage**
Setup
=====
When this frontend is included in :attr:`mopidy.settings.FRONTENDS`, it starts
a web server at the port specified by :attr:`mopidy.settings.HTTP_SERVER_PORT`.
As a simple security measure, the web server is by default only available from
localhost. To make it available from other computers, change
:attr:`mopidy.settings.HTTP_SERVER_HOSTNAME`. Before you do so, note that the
HTTP frontend does not feature any form of user authentication or
authorization. Anyone able to access the web server can use the full core API
of Mopidy. Thus, you probably only want to make the web server available from
your local network or place it behind a web proxy which takes care or user
authentication. You have been warned.
.. warning:: Security
This web server exposes a WebSocket at ``/mopidy/ws/``. The WebSocket gives you
access to Mopidy's full API and enables Mopidy to instantly push events to the
client, as they happen.
As a simple security measure, the web server is by default only available
from localhost. To make it available from other computers, change
:attr:`mopidy.settings.HTTP_SERVER_HOSTNAME`. Before you do so, note that
the HTTP frontend does not feature any form of user authentication or
authorization. Anyone able to access the web server can use the full core
API of Mopidy. Thus, you probably only want to make the web server
available from your local network or place it behind a web proxy which
takes care or user authentication. You have been warned.
Using a web based Mopidy client
===============================
The web server can also host any static files, for example the HTML, CSS,
JavaScript and images needed by a web based Mopidy client. To host static
JavaScript, and images needed for a web based Mopidy client. To host static
files, change :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` to point to the
directory you want to serve.
root directory of your web client, e.g.::
**WebSocket API**
HTTP_SERVER_STATIC_DIR = u'/home/alice/dev/the-client'
If the directory includes a file named ``index.html``, it will be served on the
root of Mopidy's web server.
If you're making a web based client and wants to do server side development as
well, you are of course free to run your own web server and just use Mopidy's
web server for the APIs. But, for clients implemented purely in JavaScript,
letting Mopidy host the files is a simpler solution.
WebSocket API
=============
.. warning:: API stability
Since this frontend exposes our internal core API directly it is to be
regarded as **experimental**. We cannot promise to keep any form of
backwards compatibility between releases as we will need to change the core
API while working out how to support new use cases. Thus, if you use this
API, you must expect to do small adjustments to your client for every
release of Mopidy.
From Mopidy 1.0 and onwards, we intend to keep the core API far more
stable.
The web server exposes a WebSocket at ``/mopidy/ws/``. The WebSocket gives you
access to Mopidy's full API and enables Mopidy to instantly push events to the
client, as they happen.
On the WebSocket we send two different kind of messages: The client can send
JSON-RPC 2.0 requests, and the server will respond with JSON-RPC 2.0 responses.
In addition, the server will send event messages when something happens on the
server. Both message types are encoded as JSON objects.
Event messages
--------------
Event objects will always have a key named ``event`` whose value is the event
type. Depending on the event type, the event may include additional fields for
related data. The events maps directly to the :class:`mopidy.core.CoreListener`
@ -54,6 +91,10 @@ fields on the event objects. Example event message::
{"event": "track_playback_started", "track": {...}}
JSON-RPC 2.0 messaging
----------------------
JSON-RPC 2.0 messages can be recognized by checking for the key named
``jsonrpc`` with the string value ``2.0``. For details on the messaging format,
please refer to the `JSON-RPC 2.0 spec
@ -66,7 +107,7 @@ JSON-RPC calls over the WebSocket. For example,
The core API's attributes is made available through setters and getters. For
example, the attribute :attr:`mopidy.core.PlaybackController.current_track` is
availableas the JSON-RPC method ``core.playback.get_current_track`.
available as the JSON-RPC method ``core.playback.get_current_track``.
Example JSON-RPC request::
@ -80,20 +121,11 @@ The JSON-RPC method ``core.describe`` returns a data structure describing all
available methods. If you're unsure how the core API maps to JSON-RPC, having a
look at the ``core.describe`` response can be helpful.
**JavaScript wrapper**
JavaScript wrapper
==================
A JavaScript library wrapping the JSON-RPC over WebSocket API is under
development. Details on it will appear here when it's released.
**API stability**
Since this frontend exposes our internal core API directly it is to be regarded
as **experimental**. We cannot promise to keep any form of backwards
compatibility between releases as we will need to change the core API while
working out how to support new use cases. Thus, if you use this API, you must
expect to do small adjustments to your client for every release of Mopidy.
From Mopidy 1.0 and onwards, we intend to keep the core API far more stable.
"""
# flake8: noqa

View File

@ -55,6 +55,7 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
logger.debug('HTTP server will serve "%s" at /', static_dir)
mopidy_dir = os.path.join(os.path.dirname(__file__), 'data')
favicon = os.path.join(mopidy_dir, 'favicon.png')
config = {
b'/': {
@ -62,6 +63,10 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
'tools.staticdir.index': 'index.html',
'tools.staticdir.dir': static_dir,
},
b'/favicon.ico': {
'tools.staticfile.on': True,
'tools.staticfile.filename': favicon,
},
b'/mopidy': {
'tools.staticdir.on': True,
'tools.staticdir.index': 'mopidy.html',
@ -93,42 +98,8 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
cherrypy.engine.exit()
logger.info('Stopped HTTP server')
def track_playback_paused(self, **data):
self._broadcast_event('track_playback_paused', data)
def track_playback_resumed(self, **data):
self._broadcast_event('track_playback_resumed', data)
def track_playback_started(self, **data):
self._broadcast_event('track_playback_started', data)
def track_playback_ended(self, **data):
self._broadcast_event('track_playback_ended', data)
def playback_state_changed(self, **data):
self._broadcast_event('playback_state_changed', data)
def tracklist_changed(self, **data):
self._broadcast_event('tracklist_changed', data)
def playlists_loaded(self, **data):
self._broadcast_event('playlists_loaded', data)
def playlist_changed(self, **data):
self._broadcast_event('playlist_changed', data)
def options_changed(self, **data):
self._broadcast_event('options_changed', data)
def volume_changed(self, **data):
self._broadcast_event('volume_changed', data)
def seeked(self, **data):
self._broadcast_event('seeked', data)
def _broadcast_event(self, name, data):
event = {}
event.update(data)
def on_event(self, name, **data):
event = data
event['event'] = name
message = json.dumps(event, cls=models.ModelJSONEncoder)
cherrypy.engine.publish('websocket-broadcast', TextMessage(message))

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@ -35,6 +35,8 @@ def format_dependency_list(adapters=None):
pylast_info,
dbus_info,
serial_info,
cherrypy_info,
ws4py_info,
]
lines = []
@ -189,3 +191,25 @@ def serial_info():
except ImportError:
pass
return dep_info
def cherrypy_info():
dep_info = {'name': 'cherrypy'}
try:
import cherrypy
dep_info['version'] = cherrypy.__version__
dep_info['path'] = cherrypy.__file__
except ImportError:
pass
return dep_info
def ws4py_info():
dep_info = {'name': 'ws4py'}
try:
import ws4py
dep_info['version'] = ws4py.__version__
dep_info['path'] = ws4py.__file__
except ImportError:
pass
return dep_info

View File

@ -1,5 +1,7 @@
from __future__ import unicode_literals
import mock
from mopidy import audio
from tests import unittest
@ -9,6 +11,15 @@ class AudioListenerTest(unittest.TestCase):
def setUp(self):
self.listener = audio.AudioListener()
def test_on_event_forwards_to_specific_handler(self):
self.listener.state_changed = mock.Mock()
self.listener.on_event(
'state_changed', old_state='stopped', new_state='playing')
self.listener.state_changed.assert_called_with(
old_state='stopped', new_state='playing')
def test_listener_has_default_impl_for_reached_end_of_stream(self):
self.listener.reached_end_of_stream()

View File

@ -1,13 +1,22 @@
from __future__ import unicode_literals
import mock
from mopidy.backends.listener import BackendListener
from tests import unittest
class CoreListenerTest(unittest.TestCase):
class BackendListenerTest(unittest.TestCase):
def setUp(self):
self.listener = BackendListener()
def test_on_event_forwards_to_specific_handler(self):
self.listener.playlists_loaded = mock.Mock()
self.listener.on_event('playlists_loaded')
self.listener.playlists_loaded.assert_called_with()
def test_listener_has_default_impl_for_playlists_loaded(self):
self.listener.playlists_loaded()

View File

@ -1,5 +1,7 @@
from __future__ import unicode_literals
import mock
from mopidy.core import CoreListener, PlaybackState
from mopidy.models import Playlist, Track
@ -10,6 +12,15 @@ class CoreListenerTest(unittest.TestCase):
def setUp(self):
self.listener = CoreListener()
def test_on_event_forwards_to_specific_handler(self):
self.listener.track_playback_paused = mock.Mock()
self.listener.on_event(
'track_playback_paused', track=Track(), position=0)
self.listener.track_playback_paused.assert_called_with(
track=Track(), position=0)
def test_listener_has_default_impl_for_track_playback_paused(self):
self.listener.track_playback_paused(Track(), 0)

View File

@ -1,21 +1,29 @@
import json
import cherrypy
try:
import cherrypy
except ImportError:
cherrypy = False
import mock
from mopidy.frontends.http import HttpFrontend
from mopidy.exceptions import OptionalDependencyError
try:
from mopidy.frontends.http import HttpFrontend
except OptionalDependencyError:
pass
from tests import unittest
@mock.patch.object(cherrypy.engine, 'publish')
@unittest.skipUnless(cherrypy, 'cherrypy not found')
@mock.patch('cherrypy.engine.publish')
class HttpEventsTest(unittest.TestCase):
def setUp(self):
self.http = HttpFrontend(core=mock.Mock())
def test_track_playback_paused_is_broadcasted(self, publish):
publish.reset_mock()
self.http.track_playback_paused(foo='bar')
self.http.on_event('track_playback_paused', foo='bar')
self.assertEqual(publish.call_args[0][0], 'websocket-broadcast')
self.assertDictEqual(
json.loads(str(publish.call_args[0][1])), {
@ -25,100 +33,10 @@ class HttpEventsTest(unittest.TestCase):
def test_track_playback_resumed_is_broadcasted(self, publish):
publish.reset_mock()
self.http.track_playback_resumed(foo='bar')
self.http.on_event('track_playback_resumed', foo='bar')
self.assertEqual(publish.call_args[0][0], 'websocket-broadcast')
self.assertDictEqual(
json.loads(str(publish.call_args[0][1])), {
'event': 'track_playback_resumed',
'foo': 'bar',
})
def test_track_playback_started_is_broadcasted(self, publish):
publish.reset_mock()
self.http.track_playback_started(foo='bar')
self.assertEqual(publish.call_args[0][0], 'websocket-broadcast')
self.assertDictEqual(
json.loads(str(publish.call_args[0][1])), {
'event': 'track_playback_started',
'foo': 'bar',
})
def test_track_playback_ended_is_broadcasted(self, publish):
publish.reset_mock()
self.http.track_playback_ended(foo='bar')
self.assertEqual(publish.call_args[0][0], 'websocket-broadcast')
self.assertDictEqual(
json.loads(str(publish.call_args[0][1])), {
'event': 'track_playback_ended',
'foo': 'bar',
})
def test_playback_state_changed_is_broadcasted(self, publish):
publish.reset_mock()
self.http.playback_state_changed(foo='bar')
self.assertEqual(publish.call_args[0][0], 'websocket-broadcast')
self.assertDictEqual(
json.loads(str(publish.call_args[0][1])), {
'event': 'playback_state_changed',
'foo': 'bar',
})
def test_tracklist_changed_is_broadcasted(self, publish):
publish.reset_mock()
self.http.tracklist_changed(foo='bar')
self.assertEqual(publish.call_args[0][0], 'websocket-broadcast')
self.assertDictEqual(
json.loads(str(publish.call_args[0][1])), {
'event': 'tracklist_changed',
'foo': 'bar',
})
def test_playlists_loaded_is_broadcasted(self, publish):
publish.reset_mock()
self.http.playlists_loaded(foo='bar')
self.assertEqual(publish.call_args[0][0], 'websocket-broadcast')
self.assertDictEqual(
json.loads(str(publish.call_args[0][1])), {
'event': 'playlists_loaded',
'foo': 'bar',
})
def test_playlist_changed_is_broadcasted(self, publish):
publish.reset_mock()
self.http.playlist_changed(foo='bar')
self.assertEqual(publish.call_args[0][0], 'websocket-broadcast')
self.assertDictEqual(
json.loads(str(publish.call_args[0][1])), {
'event': 'playlist_changed',
'foo': 'bar',
})
def test_options_changed_is_broadcasted(self, publish):
publish.reset_mock()
self.http.options_changed(foo='bar')
self.assertEqual(publish.call_args[0][0], 'websocket-broadcast')
self.assertDictEqual(
json.loads(str(publish.call_args[0][1])), {
'event': 'options_changed',
'foo': 'bar',
})
def test_volume_changed_is_broadcasted(self, publish):
publish.reset_mock()
self.http.volume_changed(foo='bar')
self.assertEqual(publish.call_args[0][0], 'websocket-broadcast')
self.assertDictEqual(
json.loads(str(publish.call_args[0][1])), {
'event': 'volume_changed',
'foo': 'bar',
})
def test_seeked_is_broadcasted(self, publish):
publish.reset_mock()
self.http.seeked(foo='bar')
self.assertEqual(publish.call_args[0][0], 'websocket-broadcast')
self.assertDictEqual(
json.loads(str(publish.call_args[0][1])), {
'event': 'seeked',
'foo': 'bar',
})

View File

@ -27,6 +27,16 @@ try:
except ImportError:
spotify = False
try:
import cherrypy
except ImportError:
cherrypy = False
try:
import ws4py
except ImportError:
ws4py = False
from mopidy.utils import deps
from tests import unittest
@ -115,3 +125,19 @@ class DepsTest(unittest.TestCase):
self.assertEquals('pyserial', result['name'])
self.assertEquals(serial.VERSION, result['version'])
self.assertIn('serial', result['path'])
@unittest.skipUnless(cherrypy, 'cherrypy not found')
def test_cherrypy_info(self):
result = deps.cherrypy_info()
self.assertEquals('cherrypy', result['name'])
self.assertEquals(cherrypy.__version__, result['version'])
self.assertIn('cherrypy', result['path'])
@unittest.skipUnless(ws4py, 'ws4py not found')
def test_ws4py_info(self):
result = deps.ws4py_info()
self.assertEquals('ws4py', result['name'])
self.assertEquals(ws4py.__version__, result['version'])
self.assertIn('ws4py', result['path'])

View File

@ -260,7 +260,7 @@ class JsonRpcBatchTest(JsonRpcTestBase):
self.assertEqual(len(response), 3)
response = {row['id']: row for row in response}
response = dict((row['id'], row) for row in response)
self.assertEqual(response[1]['result'], False)
self.assertEqual(response[2]['result'], True)
self.assertEqual(response[3]['result'], False)
@ -277,7 +277,7 @@ class JsonRpcBatchTest(JsonRpcTestBase):
self.assertEqual(len(response), 2)
response = {row['id']: row for row in response}
response = dict((row['id'], row) for row in response)
self.assertNotIn(1, response)
self.assertEqual(response[2]['result'], True)
self.assertEqual(response[3]['result'], False)
@ -313,7 +313,7 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase):
data = error['data']
self.assertEqual(data['type'], 'ValueError')
self.assertEqual(data['message'], "u'bogus' is not in list")
self.assertIn('not in list', data['message'])
self.assertIn('traceback', data)
self.assertIn('Traceback (most recent call last):', data['traceback'])
@ -522,7 +522,7 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase):
response = self.jrw.handle_data(request)
self.assertEqual(len(response), 5)
response = {row['id']: row for row in response}
response = dict((row['id'], row) for row in response)
self.assertEqual(response['1']['result'], None)
self.assertEqual(response['2']['result'], None)
self.assertEqual(response[None]['error']['code'], -32600)