Merge pull request #235 from jodal/feature/http-frontend

HTTP/WebSocket frontend
This commit is contained in:
Thomas Adamcik 2012-11-30 19:02:08 -08:00
commit 1ee2935867
18 changed files with 585 additions and 1 deletions

View File

@ -5,6 +5,7 @@ install:
- "sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list"
- "sudo apt-get update || true"
- "sudo apt-get install $(apt-cache depends mopidy | awk '$2 !~ /mopidy/ {print $2}')"
- "pip install -r requirements/http.txt" # Until ws4py is packaged as a .deb
before_script:
- "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt"

View File

@ -7,6 +7,7 @@ include mopidy/backends/spotify/spotify_appkey.key
include pylintrc
recursive-include docs *
prune docs/_build
recursive-include mopidy/frontends/http/data/
recursive-include requirements *
recursive-include tests *.py
recursive-include tests/data *

View File

@ -39,6 +39,7 @@ class Mock(object):
MOCK_MODULES = [
'cherrypy',
'dbus',
'dbus.mainloop',
'dbus.mainloop.glib',
@ -53,6 +54,11 @@ MOCK_MODULES = [
'pykka.registry',
'pylast',
'serial',
'ws4py',
'ws4py.messaging',
'ws4py.server',
'ws4py.server.cherrypyserver',
'ws4py.websocket',
]
for mod_name in MOCK_MODULES:
sys.modules[mod_name] = Mock()

View File

@ -0,0 +1,8 @@
.. _http-frontend:
*********************************************
:mod:`mopidy.frontends.http` -- HTTP frontend
*********************************************
.. automodule:: mopidy.frontends.http
:synopsis: HTTP and WebSockets frontend

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

@ -0,0 +1,132 @@
"""
The HTTP frontends lets you control Mopidy through HTTP and WebSockets, e.g.
from a web based client.
**Dependencies**
- ``cherrypy``
- ``ws4py``
**Settings**
- :attr:`mopidy.settings.HTTP_SERVER_HOSTNAME`
- :attr:`mopidy.settings.HTTP_SERVER_PORT`
- :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR`
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`.
.. warning:: Security
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 for a web based Mopidy client. To host static
files, change :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` to point to the
root directory of your web client, e.g.::
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`
API. Refer to the ``CoreListener`` method names is the available event types.
The ``CoreListener`` method's keyword arguments are all included as extra
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
<http://www.jsonrpc.org/specification>`_.
All methods (not attributes) in the :ref:`core-api` is made available through
JSON-RPC calls over the WebSocket. For example,
:meth:`mopidy.core.PlaybackController.play` is available as the JSON-RPC method
``core.playback.play``.
The core API's attributes is made available through setters and getters. For
example, the attribute :attr:`mopidy.core.PlaybackController.current_track` is
available as the JSON-RPC method ``core.playback.get_current_track``.
Example JSON-RPC request::
{"jsonrpc": "2.0", "id": 1, "method": "core.playback.get_current_track"}
Example JSON-RPC response::
{"jsonrpc": "2.0", "id": 1, "result": {"__model__": "Track", ...}}
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
==================
A JavaScript library wrapping the JSON-RPC over WebSocket API is under
development. Details on it will appear here when it's released.
"""
# flake8: noqa
from .actor import HttpFrontend

View File

@ -0,0 +1,113 @@
from __future__ import unicode_literals
import logging
import json
import os
import pykka
from mopidy import exceptions, models, settings
from mopidy.core import CoreListener
try:
import cherrypy
from ws4py.messaging import TextMessage
from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool
except ImportError as import_error:
raise exceptions.OptionalDependencyError(import_error)
from . import ws
logger = logging.getLogger('mopidy.frontends.http')
class HttpFrontend(pykka.ThreadingActor, CoreListener):
def __init__(self, core):
super(HttpFrontend, self).__init__()
self.core = core
self._setup_server()
self._setup_websocket_plugin()
app = self._create_app()
self._setup_logging(app)
def _setup_server(self):
cherrypy.config.update({
'engine.autoreload_on': False,
'server.socket_host': (
settings.HTTP_SERVER_HOSTNAME.encode('utf-8')),
'server.socket_port': settings.HTTP_SERVER_PORT,
})
def _setup_websocket_plugin(self):
WebSocketPlugin(cherrypy.engine).subscribe()
cherrypy.tools.websocket = WebSocketTool()
def _create_app(self):
root = RootResource()
root.mopidy = MopidyResource()
root.mopidy.ws = ws.WebSocketResource(self.core)
if settings.HTTP_SERVER_STATIC_DIR:
static_dir = settings.HTTP_SERVER_STATIC_DIR
else:
static_dir = os.path.join(os.path.dirname(__file__), 'data')
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'/': {
'tools.staticdir.on': True,
'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',
'tools.staticdir.dir': mopidy_dir,
},
b'/mopidy/ws': {
'tools.websocket.on': True,
'tools.websocket.handler_cls': ws.WebSocketHandler,
},
}
return cherrypy.tree.mount(root, '/', config)
def _setup_logging(self, app):
cherrypy.log.access_log.setLevel(logging.NOTSET)
cherrypy.log.error_log.setLevel(logging.NOTSET)
cherrypy.log.screen = False
app.log.access_log.setLevel(logging.NOTSET)
app.log.error_log.setLevel(logging.NOTSET)
def on_start(self):
logger.debug('Starting HTTP server')
cherrypy.engine.start()
logger.info('HTTP server running at %s', cherrypy.server.base())
def on_stop(self):
logger.debug('Stopping HTTP server')
cherrypy.engine.exit()
logger.info('Stopped HTTP server')
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))
class RootResource(object):
pass
class MopidyResource(object):
pass

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Mopidy HTTP frontend</title>
<link rel="stylesheet" type="text/css" href="mopidy.css">
</head>
<body>
<div class="box focus">
<h1>Mopidy HTTP&nbsp;frontend</h1>
<p>This web server is a part of the music server Mopidy. To learn more
about Mopidy, please visit
<a href="http://www.mopidy.com/">www.mopidy.com</a>.</p>
</div>
<div class="box">
<h2>Static content serving</h2>
<p>To see your own content instead of this placeholder page, change the
setting <tt>HTTP_SERVER_STATIC_DIR</tt> to point to the directory
containing your static files. This can be used to host e.g. a pure
HTML/CSS/JavaScript Mopidy client.</p>
<p>If you replace this page with your own content, the Mopidy resources
at <a href="/mopidy/">/mopidy/</a> will still be available.</p>
</div>
</body>
</html>

View File

@ -0,0 +1,75 @@
html {
background: #e8ecef;
color: #555;
font-family: "Droid Serif", "Georgia", "Times New Roman", "Palatino",
"Hoefler Text", "Baskerville", serif;
font-size: 150%;
line-height: 1.4em;
}
body {
max-width: 20em;
margin: 0 auto;
}
div.box {
background: white;
border-radius: 5px;
box-shadow: 5px 5px 5px #d8dcdf;
margin: 2em 0;
padding: 1em;
}
div.box.focus {
background: #465158;
color: #e8ecef;
}
div.icon {
float: right;
}
h1, h2 {
font-family: "Ubuntu", "Arial", "Helvetica", "Lucida Grande",
"Verdana", "Gill Sans", sans-serif;
line-height: 1.1em;
}
h2 {
margin: 0.2em 0 0;
}
p.next {
text-align: right;
}
a {
color: #555;
text-decoration: none;
border-bottom: 1px dotted;
}
img {
border: 0;
}
code, pre {
font-family: "Droid Sans Mono", Menlo, Courier New, Courier, Mono, monospace;
font-size: 9pt;
line-height: 1.2em;
padding: 0.5em 1em;
margin: 1em 0;
white-space: pre;
overflow: auto;
}
.box code,
.box pre {
background: #e8ecef;
color: #555;
}
.box a {
color: #465158;
}
.box a:hover {
opacity: 0.8;
}
.box.focus a {
color: #e8ecef;
}
.center {
text-align: center;
}
#ws-console {
height: 200px;
overflow: auto;
}

View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Mopidy HTTP frontend</title>
<link rel="stylesheet" type="text/css" href="mopidy.css">
</head>
<body>
<div class="box focus">
<h1>Mopidy HTTP&nbsp;frontend</h1>
<p>This web server is a part of the music server Mopidy. To learn more
about Mopidy, please visit <a
href="http://www.mopidy.com/">www.mopidy.com</a>.</p>
</div>
<div class="box">
<h2>WebSocket endpoint</h2>
<p>Mopidy has a WebSocket endpoint at <tt>/mopidy/ws/</tt>. You can use
this end point to access Mopidy's full API, and to get notified about
events happening in Mopidy.</p>
</div>
<div class="box">
<h2>Example</h2>
<p>Here you can see events arriving from Mopidy in real time:</p>
<pre id="ws-console"></pre>
<p>Nothing to see? Try playing a track using your MPD client.</p>
</div>
<div class="box focus">
<h2>Documentation</h2>
<p>For more information, please refer to the Mopidy documentation at
<a href="http://docs.mopidy.com/">docs.mopidy.com</a>.</p>
</div>
<script type="text/javascript">
var ws = new WebSocket("ws://" + document.location.host + "/mopidy/ws/");
ws.onmessage = function (message) {
var console = document.getElementById('ws-console');
var newLine = (new Date()).toLocaleTimeString() + ": " +
message.data + "\n";
console.innerHTML = newLine + console.innerHTML;
};
</script>
</body>
</html>

View File

@ -0,0 +1,72 @@
from __future__ import unicode_literals
import logging
from mopidy import core, exceptions, models
from mopidy.utils import jsonrpc
try:
import cherrypy
from ws4py.websocket import WebSocket
except ImportError as import_error:
raise exceptions.OptionalDependencyError(import_error)
logger = logging.getLogger('mopidy.frontends.http')
class WebSocketResource(object):
def __init__(self, core_proxy):
self._core = core_proxy
inspector = jsonrpc.JsonRpcInspector(
objects={
'core.library': core.LibraryController,
'core.playback': core.PlaybackController,
'core.playlists': core.PlaylistsController,
'core.tracklist': core.TracklistController,
})
self.jsonrpc = jsonrpc.JsonRpcWrapper(
objects={
'core.describe': inspector.describe,
'core.library': self._core.library,
'core.playback': self._core.playback,
'core.playlists': self._core.playlists,
'core.tracklist': self._core.tracklist,
},
decoders=[models.model_json_decoder],
encoders=[models.ModelJSONEncoder])
@cherrypy.expose
def index(self):
logger.debug('WebSocket handler created')
cherrypy.request.ws_handler.jsonrpc = self.jsonrpc
class WebSocketHandler(WebSocket):
def opened(self):
remote = cherrypy.request.remote
logger.debug(
'New WebSocket connection from %s:%d',
remote.ip, remote.port)
def closed(self, code, reason=None):
remote = cherrypy.request.remote
logger.debug(
'Closed WebSocket connection from %s:%d '
'with code %s and reason %r',
remote.ip, remote.port, code, reason)
def received_message(self, request):
remote = cherrypy.request.remote
request = str(request)
logger.debug(
'Received WebSocket message from %s:%d: %r',
remote.ip, remote.port, request)
response = self.jsonrpc.handle_json(request)
if response:
self.send(response)
logger.debug(
'Sent WebSocket message to %s:%d: %r',
remote.ip, remote.port, response)

View File

@ -80,6 +80,39 @@ FRONTENDS = (
'mopidy.frontends.mpris.MprisFrontend',
)
#: Which address Mopidy's HTTP server should bind to.
#:
#: Used by :mod:`mopidy.frontends.http`.
#:
#: Examples:
#:
#: ``127.0.0.1``
#: Listens only on the IPv4 loopback interface. Default.
#: ``::1``
#: Listens only on the IPv6 loopback interface.
#: ``0.0.0.0``
#: Listens on all IPv4 interfaces.
#: ``::``
#: Listens on all interfaces, both IPv4 and IPv6.
HTTP_SERVER_HOSTNAME = u'127.0.0.1'
#: Which TCP port Mopidy's HTTP server should listen to.
#:
#: Used by :mod:`mopidy.frontends.http`.
#:
#: Default: 6680
HTTP_SERVER_PORT = 6680
#: Which directory Mopidy's HTTP server should serve at /.
#:
#: Change this to have Mopidy serve e.g. files for your JavaScript client.
#: /api and /ws will continue to work as usual even if you change this setting.
#:
#: Used by :mod:`mopidy.frontends.http`.
#:
#: Default: None
HTTP_SERVER_STATIC_DIR = None
#: Your `Last.fm <http://www.last.fm/>`_ username.
#:
#: Used by :mod:`mopidy.frontends.lastfm`.

View File

@ -46,6 +46,9 @@ def setup_console_logging(verbosity_level):
if verbosity_level < 3:
logging.getLogger('pykka').setLevel(logging.INFO)
if verbosity_level < 2:
logging.getLogger('cherrypy').setLevel(logging.WARNING)
def setup_debug_logging_to_file():
formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT)

2
requirements/http.txt Normal file
View File

@ -0,0 +1,2 @@
cherrypy >= 3.2.2
ws4py >= 0.2.3

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

View File

@ -0,0 +1,34 @@
import json
import cherrypy
import mock
from mopidy.frontends.http import HttpFrontend
from tests import unittest
@mock.patch.object(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.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])), {
'event': 'track_playback_paused',
'foo': 'bar',
})
def test_track_playback_resumed_is_broadcasted(self, publish):
publish.reset_mock()
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',
})