diff --git a/docs/api/http.rst b/docs/api/http.rst
index 5561955d..e72d72b6 100644
--- a/docs/api/http.rst
+++ b/docs/api/http.rst
@@ -27,7 +27,7 @@ Node.js.
WebSocket API
=============
-The web server exposes a WebSocket at ``/mopidy/ws/``. The WebSocket gives you
+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.
diff --git a/docs/ext/http.rst b/docs/ext/http.rst
index 34c77828..7635a9fd 100644
--- a/docs/ext/http.rst
+++ b/docs/ext/http.rst
@@ -46,17 +46,30 @@ See :ref:`http-api` for details on how to integrate with Mopidy over HTTP. If
you're looking for a web based client for Mopidy, go check out
:ref:`http-clients`.
+Extending server
+================
+
+If you wish to extend server additional service side functionality you must
+create class that implements the :class:`mopidy.http.Router` interface and
+install it in the extension registry under ``http:router``.
+The default implementation already supports serving static files. If you just
+want to serve static files you only need to define class variable :attr:`mopidy
+.http.Router.name` and :attr:`mopidy.http.Router.path`, for example::
+
+ class WebClient(http.Router):
+ name = 'webclient'
+ path = os.path.join(os.path.dirname(__file__), 'public_html')
+
+If you wish to extend server with custom methods you can override class method
+``mopidy.http.Router.setup_routes`` and define custom routes.
+
Dependencies
============
In addition to Mopidy's dependencies, Mopidy-HTTP requires the following:
-- cherrypy >= 3.2.2. Available as python-cherrypy3 in Debian/Ubuntu.
-
-- ws4py >= 0.2.3. Available as python-ws4py in newer Debian/Ubuntu and from
- `apt.mopidy.com `__ for older releases of
- Debian/Ubuntu.
+- tornado >= 3.1.1 Available as python-tornado in Debian/Ubuntu.
If you're installing Mopidy with pip, you can run the following command to
install Mopidy with the extra dependencies for required for Mopidy-HTTP::
diff --git a/mopidy/http/__init__.py b/mopidy/http/__init__.py
index 25e2dd46..00f01082 100644
--- a/mopidy/http/__init__.py
+++ b/mopidy/http/__init__.py
@@ -1,40 +1,103 @@
from __future__ import unicode_literals
+import logging
import os
-import mopidy
-from mopidy import config, exceptions, ext
+from mopidy import __version__, config as config_lib, exceptions, ext
+
+import tornado.web
+
+
+logger = logging.getLogger(__name__)
class Extension(ext.Extension):
-
dist_name = 'Mopidy-HTTP'
ext_name = 'http'
- version = mopidy.__version__
+ version = __version__
def get_default_config(self):
conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf')
- return config.read(conf_file)
+ return config_lib.read(conf_file)
def get_config_schema(self):
schema = super(Extension, self).get_config_schema()
- schema['hostname'] = config.Hostname()
- schema['port'] = config.Port()
- schema['static_dir'] = config.Path(optional=True)
- schema['zeroconf'] = config.String(optional=True)
+ schema['hostname'] = config_lib.Hostname()
+ schema['port'] = config_lib.Port()
+ schema['static_dir'] = config_lib.Path(optional=True)
+ schema['zeroconf'] = config_lib.String(optional=True)
return schema
def validate_environment(self):
try:
- import cherrypy # noqa
+ import tornado.web # noqa
except ImportError as e:
- raise exceptions.ExtensionError('cherrypy library not found', e)
-
- try:
- import ws4py # noqa
- except ImportError as e:
- raise exceptions.ExtensionError('ws4py library not found', e)
+ raise exceptions.ExtensionError('tornado library not found', e)
def setup(self, registry):
from .actor import HttpFrontend
+
+ HttpFrontend.routers = registry['http:routers']
registry.add('frontend', HttpFrontend)
+
+
+class StaticFileHandler(tornado.web.StaticFileHandler):
+ def set_extra_headers(self, path):
+ self.set_header("Cache-Control", "no-cache")
+ self.set_header("X-Mopidy-Version", __version__.encode('utf-8'))
+
+
+class Router(object):
+ """
+ HTTP router interface.
+
+ Extensions that wish to add custom routes to HTTP server
+ need to sub-class this class and install and configure it with an
+ extension.
+
+ :param config:dict Config dictionary
+ """
+
+ #: Name of the HTTP router implementation, must be overridden.
+ name = None
+
+ #: Path to location of static files.
+ path = None
+
+ def __init__(self, config):
+ self.config = config
+ self.hostname = config['http']['hostname']
+ self.port = config['http']['port']
+ if not self.name:
+ raise ValueError('Undefined name in %s' % self)
+
+ def linkify(self):
+ """
+ Absolute URL to the root of this router.
+
+ :return string: URI
+ """
+ return 'http://%s:%s/%s/' % (self.hostname, self.port, self.name)
+
+ def setup_routes(self):
+ """
+ Configure routes for this interface
+
+ Implementation must return list of routes, compatible with
+ :`class:tornado.web.Application`.
+
+ :return list: List of tornado routes
+ """
+
+ if not self.path:
+ raise ValueError('Undefined path in %s' % self)
+ logger.info(
+ 'Serving HTTP extension %s at %s' %
+ (type(self), self.linkify())
+ )
+ return [
+ (r"/%s/(.*)" % self.name, StaticFileHandler, {
+ 'path': self.path,
+ 'default_filename': 'index.html'
+ })
+ ]
diff --git a/mopidy/http/actor.py b/mopidy/http/actor.py
index f57871df..ff8dd508 100644
--- a/mopidy/http/actor.py
+++ b/mopidy/http/actor.py
@@ -3,23 +3,25 @@ from __future__ import unicode_literals
import json
import logging
import os
-
-import cherrypy
-
-import pykka
-
-from ws4py.messaging import TextMessage
-from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool
+import threading
from mopidy import models, zeroconf
from mopidy.core import CoreListener
-from mopidy.http import ws
+from mopidy.http import StaticFileHandler, handlers
+
+import pykka
+
+import tornado.ioloop
+import tornado.web
+import tornado.websocket
logger = logging.getLogger(__name__)
class HttpFrontend(pykka.ThreadingActor, CoreListener):
+ routers = []
+
def __init__(self, config, core):
super(HttpFrontend, self).__init__()
self.config = config
@@ -29,123 +31,85 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener):
self.port = config['http']['port']
self.zeroconf_name = config['http']['zeroconf']
self.zeroconf_service = None
+ self.app = None
+ self.websocket_clients = set()
- self._setup_server()
- self._setup_websocket_plugin()
- app = self._create_app()
- self._setup_logging(app)
+ def _load_extensions(self):
+ routes = []
+ for extension in self.routers:
+ extension = extension(self.config)
+ if callable(getattr(extension, "setup_routes", None)):
+ routes.extend(extension.setup_routes())
+ logger.info('Loaded HTTP router: %s',
+ extension.__class__.__name__)
+ else:
+ logger.info(
+ 'Disabled HTTP router %s: missing setup_routes method',
+ extension.__class__.__name__)
- def _setup_server(self):
- cherrypy.config.update({
- 'engine.autoreload_on': False,
- 'server.socket_host': self.hostname,
- 'server.socket_port': self.port,
+ return routes
+
+ def _create_routes(self):
+ mopidy_dir = os.path.join(os.path.dirname(__file__), 'data')
+ static_dir = self.config['http']['static_dir']
+
+ # either default mopidy or user defined path to files
+ primary_dir = (r"/(.*)", StaticFileHandler, {
+ 'path': static_dir if static_dir else mopidy_dir,
+ 'default_filename': 'index.html'
})
- def _setup_websocket_plugin(self):
- WebSocketPlugin(cherrypy.engine).subscribe()
- cherrypy.tools.websocket = WebSocketTool()
+ routes = self._load_extensions()
+ logger.debug(
+ 'HTTP routes from extensions: %s',
+ list((l[0], l[1]) for l in routes)
+ )
- def _create_app(self):
- root = RootResource()
- root.mopidy = MopidyResource()
- root.mopidy.ws = ws.WebSocketResource(self.core)
-
- if self.config['http']['static_dir']:
- static_dir = self.config['http']['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)
+ #TODO: Dynamically define all endpoints
+ routes.extend([
+ (r"/mopidy/ws/?", handlers.WebSocketHandler, {'actor': self}),
+ (r"/mopidy/rpc", handlers.JsonRpcHandler, {'actor': self}),
+ (r"/mopidy/(.*)", StaticFileHandler, {
+ 'path': mopidy_dir, 'default_filename': 'mopidy.html'
+ }),
+ primary_dir,
+ ])
+ return routes
def on_start(self):
+ if self.zeroconf_name:
+ self.zeroconf_service = zeroconf.Zeroconf(
+ stype='_http._tcp', name=self.zeroconf_name,
+ host=self.hostname, port=self.port)
+
+ if self.zeroconf_service.publish():
+ logger.debug(
+ 'Registered HTTP with Zeroconf as "%s"',
+ self.zeroconf_service.name)
+ else:
+ logger.debug('Registering HTTP with Zeroconf failed.')
+ threading.Thread(target=self._startup).start()
+
+ def _startup(self):
logger.debug('Starting HTTP server')
- cherrypy.engine.start()
- logger.info('HTTP server running at %s', cherrypy.server.base())
- self._publish_zeroconf()
+ self.app = tornado.web.Application(self._create_routes())
+ self.app.listen(self.port, self.hostname)
+ logger.info('HTTP server running at http://%s:%s', self.hostname,
+ self.port)
+ tornado.ioloop.IOLoop.instance().start()
+
+ def _shutdown(self):
+ logger.debug('Stopping HTTP server')
+ tornado.ioloop.IOLoop.instance().stop()
+ logger.info('Stopped HTTP server')
def on_stop(self):
- logger.debug('Stopping HTTP server')
- self._unpublish_zeroconf()
- cherrypy.engine.exit()
- logger.info('Stopped HTTP server')
+ tornado.ioloop.IOLoop.instance().add_callback(self._shutdown)
+ if self.zeroconf_service:
+ self.zeroconf_service.unpublish()
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))
-
- def _publish_zeroconf(self):
- if not self.zeroconf_name:
- return
-
- self.zeroconf_http_service = zeroconf.Zeroconf(
- stype='_http._tcp', name=self.zeroconf_name,
- host=self.hostname, port=self.port)
-
- if self.zeroconf_http_service.publish():
- logger.debug(
- 'Registered HTTP with Zeroconf as "%s"',
- self.zeroconf_http_service.name)
- else:
- logger.debug('Registering HTTP with Zeroconf failed.')
-
- self.zeroconf_mopidy_http_service = zeroconf.Zeroconf(
- stype='_mopidy-http._tcp', name=self.zeroconf_name,
- host=self.hostname, port=self.port)
-
- if self.zeroconf_mopidy_http_service.publish():
- logger.debug(
- 'Registered Mopidy-HTTP with Zeroconf as "%s"',
- self.zeroconf_mopidy_http_service.name)
- else:
- logger.debug('Registering Mopidy-HTTP with Zeroconf failed.')
-
- def _unpublish_zeroconf(self):
- if self.zeroconf_http_service:
- self.zeroconf_http_service.unpublish()
-
- if self.zeroconf_mopidy_http_service:
- self.zeroconf_mopidy_http_service.unpublish()
-
-
-class RootResource(object):
- pass
-
-
-class MopidyResource(object):
- pass
+ handlers.WebSocketHandler.broadcast(self.websocket_clients, message)
diff --git a/mopidy/http/data/favicon.png b/mopidy/http/data/favicon.ico
similarity index 100%
rename from mopidy/http/data/favicon.png
rename to mopidy/http/data/favicon.ico
diff --git a/mopidy/http/data/mopidy.html b/mopidy/http/data/mopidy.html
index c756cd6c..4f859304 100644
--- a/mopidy/http/data/mopidy.html
+++ b/mopidy/http/data/mopidy.html
@@ -38,8 +38,9 @@
For more information, please refer to the Mopidy documentation at
docs.mopidy.com.
+