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..0e2cb439 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 +import tornado.web + +from mopidy import __version__, config as config_lib, exceptions, ext + + +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..707ee161 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 threading import pykka -from ws4py.messaging import TextMessage -from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool +import tornado.ioloop +import tornado.web +import tornado.websocket from mopidy import models, zeroconf from mopidy.core import CoreListener -from mopidy.http import ws +from mopidy.http import StaticFileHandler, handlers 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 extension: %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..38ea2036 100644 --- a/mopidy/http/data/mopidy.html +++ b/mopidy/http/data/mopidy.html @@ -38,6 +38,7 @@

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

+