diff --git a/.travis.yml b/.travis.yml index df08679b..7acda2bd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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" diff --git a/MANIFEST.in b/MANIFEST.in index f3723ecd..6a64cb9a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -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 * diff --git a/docs/conf.py b/docs/conf.py index d5debb46..04d15067 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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() diff --git a/docs/modules/frontends/http.rst b/docs/modules/frontends/http.rst new file mode 100644 index 00000000..31366bd1 --- /dev/null +++ b/docs/modules/frontends/http.rst @@ -0,0 +1,8 @@ +.. _http-frontend: + +********************************************* +:mod:`mopidy.frontends.http` -- HTTP frontend +********************************************* + +.. automodule:: mopidy.frontends.http + :synopsis: HTTP and WebSockets frontend diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index dc8bf1d7..7c4ab093 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -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): """ diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py new file mode 100644 index 00000000..d98734b2 --- /dev/null +++ b/mopidy/frontends/http/__init__.py @@ -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 +`_. + +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 diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py new file mode 100644 index 00000000..8ad0f026 --- /dev/null +++ b/mopidy/frontends/http/actor.py @@ -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 diff --git a/mopidy/frontends/http/data/favicon.png b/mopidy/frontends/http/data/favicon.png new file mode 100644 index 00000000..a214c91f Binary files /dev/null and b/mopidy/frontends/http/data/favicon.png differ diff --git a/mopidy/frontends/http/data/index.html b/mopidy/frontends/http/data/index.html new file mode 100644 index 00000000..85d3d331 --- /dev/null +++ b/mopidy/frontends/http/data/index.html @@ -0,0 +1,29 @@ + + + + + Mopidy HTTP frontend + + + +
+

Mopidy HTTP frontend

+ +

This web server is a part of the music server Mopidy. To learn more + about Mopidy, please visit + www.mopidy.com.

+
+ +
+

Static content serving

+ +

To see your own content instead of this placeholder page, change the + setting HTTP_SERVER_STATIC_DIR to point to the directory + containing your static files. This can be used to host e.g. a pure + HTML/CSS/JavaScript Mopidy client.

+ +

If you replace this page with your own content, the Mopidy resources + at /mopidy/ will still be available.

+
+ + diff --git a/mopidy/frontends/http/data/mopidy.css b/mopidy/frontends/http/data/mopidy.css new file mode 100644 index 00000000..c5042769 --- /dev/null +++ b/mopidy/frontends/http/data/mopidy.css @@ -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; +} diff --git a/mopidy/frontends/http/data/mopidy.html b/mopidy/frontends/http/data/mopidy.html new file mode 100644 index 00000000..c756cd6c --- /dev/null +++ b/mopidy/frontends/http/data/mopidy.html @@ -0,0 +1,51 @@ + + + + + Mopidy HTTP frontend + + + +
+

Mopidy HTTP frontend

+ +

This web server is a part of the music server Mopidy. To learn more + about Mopidy, please visit www.mopidy.com.

+
+ +
+

WebSocket endpoint

+ +

Mopidy has a WebSocket endpoint at /mopidy/ws/. You can use + this end point to access Mopidy's full API, and to get notified about + events happening in Mopidy.

+
+ +
+

Example

+ +

Here you can see events arriving from Mopidy in real time:

+ +

+
+      

Nothing to see? Try playing a track using your MPD client.

+
+ +
+

Documentation

+ +

For more information, please refer to the Mopidy documentation at + docs.mopidy.com.

+
+ + + diff --git a/mopidy/frontends/http/ws.py b/mopidy/frontends/http/ws.py new file mode 100644 index 00000000..d325c359 --- /dev/null +++ b/mopidy/frontends/http/ws.py @@ -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) diff --git a/mopidy/settings.py b/mopidy/settings.py index 2e022bc2..259bc645 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -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 `_ username. #: #: Used by :mod:`mopidy.frontends.lastfm`. diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index e503ff9f..ae4ea0d9 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -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) diff --git a/requirements/http.txt b/requirements/http.txt new file mode 100644 index 00000000..d8757e29 --- /dev/null +++ b/requirements/http.txt @@ -0,0 +1,2 @@ +cherrypy >= 3.2.2 +ws4py >= 0.2.3 diff --git a/tests/core/listener_test.py b/tests/core/listener_test.py index 2e121796..8aaf1234 100644 --- a/tests/core/listener_test.py +++ b/tests/core/listener_test.py @@ -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) diff --git a/tests/frontends/http/__init__.py b/tests/frontends/http/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/frontends/http/events_test.py b/tests/frontends/http/events_test.py new file mode 100644 index 00000000..d04eb93e --- /dev/null +++ b/tests/frontends/http/events_test.py @@ -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', + })