From 712743a0136ae9718a6b5ca3f5855b4f3b4f9d61 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 8 Nov 2012 21:39:48 +0100 Subject: [PATCH 001/144] Working PoC of a CherryPy HTTP frontend --- mopidy/frontends/cherrypyhttp.py | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 mopidy/frontends/cherrypyhttp.py diff --git a/mopidy/frontends/cherrypyhttp.py b/mopidy/frontends/cherrypyhttp.py new file mode 100644 index 00000000..453731fd --- /dev/null +++ b/mopidy/frontends/cherrypyhttp.py @@ -0,0 +1,43 @@ +from __future__ import absolute_import + +import logging + +import cherrypy +import pykka + + +logger = logging.getLogger('mopidy.frontends.cherrypyhttp') + + +class CherryPyHttpFrontend(pykka.ThreadingActor): + def __init__(self, core): + super(CherryPyHttpFrontend, self).__init__() + self.core = core + + def on_start(self): + logger.debug(u'Starting CherryPy HTTP server') + cherrypy.tree.mount(Root(self.core), '/', {}) + cherrypy.server.socket_port = 6680 + cherrypy.server.start() + logger.info(u'CherryPy HTTP server running at %s', + cherrypy.server.base()) + + def on_stop(self): + cherrypy.server.stop() + + +class Root(object): + def __init__(self, core): + self.core = core + + @cherrypy.expose + @cherrypy.tools.json_out() + def index(self): + playback_state = self.core.playback.state.get() + track = self.core.playback.current_track.get() + if track: + track = track.serialize() + return { + 'playback_state': playback_state, + 'current_track': track, + } From 06818b284d566aecb16b635b781f2e8b522392bb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 8 Nov 2012 22:50:35 +0100 Subject: [PATCH 002/144] http: Don't crash if cherrypy is missing --- mopidy/frontends/cherrypyhttp.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/cherrypyhttp.py b/mopidy/frontends/cherrypyhttp.py index 453731fd..1eaf4b90 100644 --- a/mopidy/frontends/cherrypyhttp.py +++ b/mopidy/frontends/cherrypyhttp.py @@ -2,9 +2,15 @@ from __future__ import absolute_import import logging -import cherrypy import pykka +from mopidy import exceptions + +try: + import cherrypy +except ImportError as import_error: + raise exceptions.OptionalDependencyError(import_error) + logger = logging.getLogger('mopidy.frontends.cherrypyhttp') From 93f6378cf52713ca738d0bf26e9b3b6c2201bcd8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 8 Nov 2012 22:57:22 +0100 Subject: [PATCH 003/144] http: Rename frontend to simply 'HttpFrontend' --- mopidy/frontends/{cherrypyhttp.py => http/__init__.py} | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename mopidy/frontends/{cherrypyhttp.py => http/__init__.py} (77%) diff --git a/mopidy/frontends/cherrypyhttp.py b/mopidy/frontends/http/__init__.py similarity index 77% rename from mopidy/frontends/cherrypyhttp.py rename to mopidy/frontends/http/__init__.py index 1eaf4b90..9808a12d 100644 --- a/mopidy/frontends/cherrypyhttp.py +++ b/mopidy/frontends/http/__init__.py @@ -12,20 +12,20 @@ except ImportError as import_error: raise exceptions.OptionalDependencyError(import_error) -logger = logging.getLogger('mopidy.frontends.cherrypyhttp') +logger = logging.getLogger('mopidy.frontends.http') -class CherryPyHttpFrontend(pykka.ThreadingActor): +class HttpFrontend(pykka.ThreadingActor): def __init__(self, core): - super(CherryPyHttpFrontend, self).__init__() + super(HttpFrontend, self).__init__() self.core = core def on_start(self): - logger.debug(u'Starting CherryPy HTTP server') + logger.debug(u'Starting HTTP server') cherrypy.tree.mount(Root(self.core), '/', {}) cherrypy.server.socket_port = 6680 cherrypy.server.start() - logger.info(u'CherryPy HTTP server running at %s', + logger.info(u'HTTP server running at %s', cherrypy.server.base()) def on_stop(self): From e5053c929a32493dd43958fd8acb66e40eef0e34 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 8 Nov 2012 23:07:42 +0100 Subject: [PATCH 004/144] http: Add HTTP_SERVER_{HOSTNAME,PORT} settings --- mopidy/frontends/http/__init__.py | 9 ++++++--- mopidy/settings.py | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 9808a12d..28cd92ea 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -4,7 +4,7 @@ import logging import pykka -from mopidy import exceptions +from mopidy import exceptions, settings try: import cherrypy @@ -19,11 +19,14 @@ class HttpFrontend(pykka.ThreadingActor): def __init__(self, core): super(HttpFrontend, self).__init__() self.core = core + cherrypy.config.update({ + 'server.socket_host': settings.HTTP_SERVER_HOSTNAME, + 'server.socket_port': settings.HTTP_SERVER_PORT, + }) + cherrypy.tree.mount(Root(self.core), '/') def on_start(self): logger.debug(u'Starting HTTP server') - cherrypy.tree.mount(Root(self.core), '/', {}) - cherrypy.server.socket_port = 6680 cherrypy.server.start() logger.info(u'HTTP server running at %s', cherrypy.server.base()) diff --git a/mopidy/settings.py b/mopidy/settings.py index 12acd281..36057869 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -78,6 +78,29 @@ FRONTENDS = ( u'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 + #: Your `Last.fm `_ username. #: #: Used by :mod:`mopidy.frontends.lastfm`. From 509c7c82ea77955bbc01940370d15eaee7bdefb8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 9 Nov 2012 00:32:13 +0100 Subject: [PATCH 005/144] http: Only include cherrypy in debug log --- mopidy/frontends/http/__init__.py | 11 ++++++++++- mopidy/utils/log.py | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 28cd92ea..4305ef6b 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -23,7 +23,16 @@ class HttpFrontend(pykka.ThreadingActor): 'server.socket_host': settings.HTTP_SERVER_HOSTNAME, 'server.socket_port': settings.HTTP_SERVER_PORT, }) - cherrypy.tree.mount(Root(self.core), '/') + app = cherrypy.tree.mount(Root(self.core), '/') + self._setup_logging(app) + + 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(u'Starting HTTP server') diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index bb966a1d..6bb29f8a 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -44,6 +44,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) From 5d8929986d278d97e33d425ae10bee0d29631886 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 9 Nov 2012 10:06:15 +0100 Subject: [PATCH 006/144] http: Encode the hostname to a str --- mopidy/frontends/http/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 4305ef6b..555758a8 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -20,7 +20,8 @@ class HttpFrontend(pykka.ThreadingActor): super(HttpFrontend, self).__init__() self.core = core cherrypy.config.update({ - 'server.socket_host': settings.HTTP_SERVER_HOSTNAME, + 'server.socket_host': + settings.HTTP_SERVER_HOSTNAME.encode('utf-8'), 'server.socket_port': settings.HTTP_SERVER_PORT, }) app = cherrypy.tree.mount(Root(self.core), '/') From 4d5122094f65b5ccc6a11c0c36669c79d8fde525 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 9 Nov 2012 10:07:38 +0100 Subject: [PATCH 007/144] http: Start the CherryPy bus, and not just the server --- mopidy/frontends/http/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 555758a8..f46ba1e2 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -37,12 +37,12 @@ class HttpFrontend(pykka.ThreadingActor): def on_start(self): logger.debug(u'Starting HTTP server') - cherrypy.server.start() + cherrypy.engine.start() logger.info(u'HTTP server running at %s', cherrypy.server.base()) def on_stop(self): - cherrypy.server.stop() + cherrypy.engine.stop() class Root(object): From dda5ee42f068699017feebb1548929e43c7dea11 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 9 Nov 2012 10:46:46 +0100 Subject: [PATCH 008/144] http: Turn off autoreloading --- mopidy/frontends/http/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index f46ba1e2..5e722b22 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -20,6 +20,7 @@ class HttpFrontend(pykka.ThreadingActor): super(HttpFrontend, self).__init__() self.core = core cherrypy.config.update({ + 'engine.autoreload_on': False, 'server.socket_host': settings.HTTP_SERVER_HOSTNAME.encode('utf-8'), 'server.socket_port': settings.HTTP_SERVER_PORT, From e41f9e3871a38e330dec03bf02d0a3c1749c67bf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 9 Nov 2012 10:47:17 +0100 Subject: [PATCH 009/144] http: Working WebSockets which emits playback state changes --- mopidy/frontends/http/__init__.py | 59 ++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 5e722b22..488db370 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -5,9 +5,13 @@ import logging import pykka from mopidy import exceptions, settings +from mopidy.core import CoreListener try: import cherrypy + from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool + from ws4py.websocket import WebSocket + from ws4py.messaging import TextMessage except ImportError as import_error: raise exceptions.OptionalDependencyError(import_error) @@ -15,18 +19,34 @@ except ImportError as import_error: logger = logging.getLogger('mopidy.frontends.http') -class HttpFrontend(pykka.ThreadingActor): +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, }) - app = cherrypy.tree.mount(Root(self.core), '/') - self._setup_logging(app) + + def _setup_websocket_plugin(self): + WebSocketPlugin(cherrypy.engine).subscribe() + cherrypy.tools.websocket = WebSocketTool() + + def _create_app(self): + return cherrypy.tree.mount(Root(self.core), '/', { + '/ws': { + 'tools.websocket.on': True, + 'tools.websocket.handler_cls': EventWebSocketHandler, + }, + }) def _setup_logging(self, app): cherrypy.log.access_log.setLevel(logging.NOTSET) @@ -39,11 +59,36 @@ class HttpFrontend(pykka.ThreadingActor): def on_start(self): logger.debug(u'Starting HTTP server') cherrypy.engine.start() - logger.info(u'HTTP server running at %s', - cherrypy.server.base()) + logger.info(u'HTTP server running at %s', cherrypy.server.base()) def on_stop(self): + logger.debug(u'Stopping HTTP server') cherrypy.engine.stop() + logger.info(u'Stopped HTTP server') + + def playback_state_changed(self, old_state, new_state): + cherrypy.engine.publish('websocket-broadcast', + TextMessage('playback_state_changed: %s -> %s' % ( + old_state, new_state))) + + +class EventWebSocketHandler(WebSocket): + def opened(self): + remote = cherrypy.request.remote + logger.debug(u'New WebSocket connection from %s:%d', + remote.ip, remote.port) + + def closed(self, code, reason=None): + remote = cherrypy.request.remote + logger.debug(u'Closed WebSocket connection from %s:%d ' + 'with code %s and reason %r', + remote.ip, remote.port, code, reason) + + def received_message(self, message): + remote = cherrypy.request.remote + logger.debug(u'Received WebSocket message from %s:%d: %s', + remote.ip, remote.port, message) + # This is where we would handle incoming messages from the clients class Root(object): @@ -61,3 +106,7 @@ class Root(object): 'playback_state': playback_state, 'current_track': track, } + + @cherrypy.expose + def ws(self): + logger.debug(u'WebSocket handler created') From 5b8f391bc77130620187ae582f435ec63f94e878 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 9 Nov 2012 10:51:53 +0100 Subject: [PATCH 010/144] http: Exit CherryPy engine properly --- mopidy/frontends/http/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 488db370..7294b40d 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -63,7 +63,7 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): def on_stop(self): logger.debug(u'Stopping HTTP server') - cherrypy.engine.stop() + cherrypy.engine.exit() logger.info(u'Stopped HTTP server') def playback_state_changed(self, old_state, new_state): From 37ab7c766d22f7b3f4f2d5e91566ac39106aa8e6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 9 Nov 2012 10:58:48 +0100 Subject: [PATCH 011/144] http: Move web socket code to new module --- mopidy/frontends/http/__init__.py | 32 +++++--------------------- mopidy/frontends/http/ws.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 26 deletions(-) create mode 100644 mopidy/frontends/http/ws.py diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 7294b40d..61aa5625 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -10,11 +10,12 @@ from mopidy.core import CoreListener try: import cherrypy from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool - from ws4py.websocket import WebSocket from ws4py.messaging import TextMessage except ImportError as import_error: raise exceptions.OptionalDependencyError(import_error) +from . import ws + logger = logging.getLogger('mopidy.frontends.http') @@ -41,10 +42,12 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): cherrypy.tools.websocket = WebSocketTool() def _create_app(self): - return cherrypy.tree.mount(Root(self.core), '/', { + root = Root(self.core) + root.ws = ws.WebSocketResource() + return cherrypy.tree.mount(root, '/', { '/ws': { 'tools.websocket.on': True, - 'tools.websocket.handler_cls': EventWebSocketHandler, + 'tools.websocket.handler_cls': ws.WebSocketHandler, }, }) @@ -72,25 +75,6 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): old_state, new_state))) -class EventWebSocketHandler(WebSocket): - def opened(self): - remote = cherrypy.request.remote - logger.debug(u'New WebSocket connection from %s:%d', - remote.ip, remote.port) - - def closed(self, code, reason=None): - remote = cherrypy.request.remote - logger.debug(u'Closed WebSocket connection from %s:%d ' - 'with code %s and reason %r', - remote.ip, remote.port, code, reason) - - def received_message(self, message): - remote = cherrypy.request.remote - logger.debug(u'Received WebSocket message from %s:%d: %s', - remote.ip, remote.port, message) - # This is where we would handle incoming messages from the clients - - class Root(object): def __init__(self, core): self.core = core @@ -106,7 +90,3 @@ class Root(object): 'playback_state': playback_state, 'current_track': track, } - - @cherrypy.expose - def ws(self): - logger.debug(u'WebSocket handler created') diff --git a/mopidy/frontends/http/ws.py b/mopidy/frontends/http/ws.py new file mode 100644 index 00000000..53ad651d --- /dev/null +++ b/mopidy/frontends/http/ws.py @@ -0,0 +1,37 @@ +import logging + +from mopidy import exceptions + +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): + @cherrypy.expose + def index(self): + logger.debug(u'WebSocket handler created') + + +class WebSocketHandler(WebSocket): + def opened(self): + remote = cherrypy.request.remote + logger.debug(u'New WebSocket connection from %s:%d', + remote.ip, remote.port) + + def closed(self, code, reason=None): + remote = cherrypy.request.remote + logger.debug(u'Closed WebSocket connection from %s:%d ' + 'with code %s and reason %r', + remote.ip, remote.port, code, reason) + + def received_message(self, message): + remote = cherrypy.request.remote + logger.debug(u'Received WebSocket message from %s:%d: %s', + remote.ip, remote.port, message) + # This is where we would handle incoming messages from the clients From 92dc9740289da52ad786c7ab91d68ae657384ac2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 9 Nov 2012 11:06:51 +0100 Subject: [PATCH 012/144] http: Move web service code to new module --- mopidy/frontends/http/__init__.py | 27 ++++++++------------------- mopidy/frontends/http/api.py | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 19 deletions(-) create mode 100644 mopidy/frontends/http/api.py diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 61aa5625..7b5cb049 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -14,7 +14,7 @@ try: except ImportError as import_error: raise exceptions.OptionalDependencyError(import_error) -from . import ws +from . import api, ws logger = logging.getLogger('mopidy.frontends.http') @@ -42,14 +42,16 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): cherrypy.tools.websocket = WebSocketTool() def _create_app(self): - root = Root(self.core) + root = RootResource() + root.api = api.ApiResource(self.core) root.ws = ws.WebSocketResource() - return cherrypy.tree.mount(root, '/', { + config = { '/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) @@ -75,18 +77,5 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): old_state, new_state))) -class Root(object): - def __init__(self, core): - self.core = core - - @cherrypy.expose - @cherrypy.tools.json_out() - def index(self): - playback_state = self.core.playback.state.get() - track = self.core.playback.current_track.get() - if track: - track = track.serialize() - return { - 'playback_state': playback_state, - 'current_track': track, - } +class RootResource(object): + pass diff --git a/mopidy/frontends/http/api.py b/mopidy/frontends/http/api.py new file mode 100644 index 00000000..b414c60c --- /dev/null +++ b/mopidy/frontends/http/api.py @@ -0,0 +1,23 @@ +from mopidy import exceptions + +try: + import cherrypy +except ImportError as import_error: + raise exceptions.OptionalDependencyError(import_error) + + +class ApiResource(object): + def __init__(self, core): + self.core = core + + @cherrypy.expose + @cherrypy.tools.json_out() + def index(self): + playback_state = self.core.playback.state.get() + track = self.core.playback.current_track.get() + if track: + track = track.serialize() + return { + 'playback_state': playback_state, + 'current_track': track, + } From 9b90e64dfb7197908ac5c3fa1dbfb507c6c078dd Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 9 Nov 2012 11:08:23 +0100 Subject: [PATCH 013/144] http: Move frontend actor to new module --- mopidy/frontends/http/__init__.py | 83 +------------------------------ mopidy/frontends/http/actor.py | 79 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 81 deletions(-) create mode 100644 mopidy/frontends/http/actor.py diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 7b5cb049..e740677a 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -1,81 +1,2 @@ -from __future__ import absolute_import - -import logging - -import pykka - -from mopidy import exceptions, settings -from mopidy.core import CoreListener - -try: - import cherrypy - from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool - from ws4py.messaging import TextMessage -except ImportError as import_error: - raise exceptions.OptionalDependencyError(import_error) - -from . import api, 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.api = api.ApiResource(self.core) - root.ws = ws.WebSocketResource() - config = { - '/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(u'Starting HTTP server') - cherrypy.engine.start() - logger.info(u'HTTP server running at %s', cherrypy.server.base()) - - def on_stop(self): - logger.debug(u'Stopping HTTP server') - cherrypy.engine.exit() - logger.info(u'Stopped HTTP server') - - def playback_state_changed(self, old_state, new_state): - cherrypy.engine.publish('websocket-broadcast', - TextMessage('playback_state_changed: %s -> %s' % ( - old_state, new_state))) - - -class RootResource(object): - pass +# 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..57af23d0 --- /dev/null +++ b/mopidy/frontends/http/actor.py @@ -0,0 +1,79 @@ +import logging + +import pykka + +from mopidy import exceptions, settings +from mopidy.core import CoreListener + +try: + import cherrypy + from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool + from ws4py.messaging import TextMessage +except ImportError as import_error: + raise exceptions.OptionalDependencyError(import_error) + +from . import api, 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.api = api.ApiResource(self.core) + root.ws = ws.WebSocketResource() + config = { + '/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(u'Starting HTTP server') + cherrypy.engine.start() + logger.info(u'HTTP server running at %s', cherrypy.server.base()) + + def on_stop(self): + logger.debug(u'Stopping HTTP server') + cherrypy.engine.exit() + logger.info(u'Stopped HTTP server') + + def playback_state_changed(self, old_state, new_state): + cherrypy.engine.publish('websocket-broadcast', + TextMessage('playback_state_changed: %s -> %s' % ( + old_state, new_state))) + + +class RootResource(object): + pass From 256c5a817911bc6c8bd636adcc768f7329353076 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 9 Nov 2012 11:19:40 +0100 Subject: [PATCH 014/144] http: Document existence of the new frontend --- docs/conf.py | 6 ++++++ docs/modules/frontends/http.rst | 8 ++++++++ mopidy/frontends/http/__init__.py | 34 +++++++++++++++++++++++++++++++ mopidy/frontends/http/actor.py | 2 +- 4 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 docs/modules/frontends/http.rst diff --git a/docs/conf.py b/docs/conf.py index d02303df..7e626d99 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -37,6 +37,7 @@ class Mock(object): MOCK_MODULES = [ + 'cherrypy', 'dbus', 'dbus.mainloop', 'dbus.mainloop.glib', @@ -51,6 +52,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/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index e740677a..ab5c3e22 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -1,2 +1,36 @@ +""" +Frontend which lets you control Mopidy through HTTP and WebSockets. + +**Dependencies** + +- ``cherrypy`` + +- ``ws4py`` + +**Settings** + +- :attr:`mopidy.settings.HTTP_SERVER_HOSTNAME` + +- :attr:`mopidy.settings.HTTP_SERVER_PORT` + +**Usage** + +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`. +This web server exposes both a REST web service at the URL ``/api``, and a +WebSocket at ``/ws``. + +The REST API gives you access to most Mopidy functionality, while the WebSocket +enables Mopidy to instantly push events to the client, as they happen. + +It is also the intention that the frontend should be able to host static files +for any external JavaScript client. This has currently not been implemented. + +**API stability** + +This frontend is currently to be regarded as **experimental**, and we are not +promising to keep any form of backwards compatibility between releases. +""" + # flake8: noqa from .actor import HttpFrontend diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index 57af23d0..c9ae651e 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -7,8 +7,8 @@ from mopidy.core import CoreListener try: import cherrypy - from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool from ws4py.messaging import TextMessage + from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool except ImportError as import_error: raise exceptions.OptionalDependencyError(import_error) From a2259fad57c59c40883125179f3756311819193f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 9 Nov 2012 13:27:27 +0100 Subject: [PATCH 015/144] http: Add static files hosting --- mopidy/frontends/http/__init__.py | 16 ++++++++++------ mopidy/frontends/http/actor.py | 11 +++++++++++ mopidy/settings.py | 12 +++++++++++- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index ab5c3e22..d674e1d0 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -13,18 +13,22 @@ Frontend which lets you control Mopidy through HTTP and WebSockets. - :attr:`mopidy.settings.HTTP_SERVER_PORT` +- :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` + **Usage** 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`. + This web server exposes both a REST web service at the URL ``/api``, and a -WebSocket at ``/ws``. +WebSocket at ``/ws``. The REST API gives you access to most Mopidy +functionality, while the WebSocket enables Mopidy to instantly push events to +the client, as they happen. -The REST API gives you access to most Mopidy functionality, while the WebSocket -enables Mopidy to instantly push events to the client, as they happen. - -It is also the intention that the frontend should be able to host static files -for any external JavaScript client. This has currently not been implemented. +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 +files, change :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` to point to the +directory you want to serve. **API stability** diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index c9ae651e..5c997f79 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -43,12 +43,23 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): root = RootResource() root.api = api.ApiResource(self.core) root.ws = ws.WebSocketResource() + config = { '/ws': { 'tools.websocket.on': True, 'tools.websocket.handler_cls': ws.WebSocketHandler, }, } + + if settings.HTTP_SERVER_STATIC_DIR: + logger.debug(u'HTTP server will serve "%s" at /', + settings.HTTP_SERVER_STATIC_DIR) + config['/'] = { + 'tools.staticdir.on': True, + 'tools.staticdir.index': 'index.html', + 'tools.staticdir.dir': settings.HTTP_SERVER_STATIC_DIR, + } + return cherrypy.tree.mount(root, '/', config) def _setup_logging(self, app): diff --git a/mopidy/settings.py b/mopidy/settings.py index 36057869..3603eda6 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -49,7 +49,7 @@ DEBUG_LOG_FILENAME = u'mopidy.log' #: get a SIGUSR1. Mainly a debug tool for figuring out deadlocks. #: #: Default:: -#: +#: #: DEBUG_THREAD = False DEBUG_THREAD = False @@ -101,6 +101,16 @@ HTTP_SERVER_HOSTNAME = u'127.0.0.1' #: 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`. From 9d2732703dbab0f308cfc56a6a5013e176ac2bd1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 9 Nov 2012 14:22:54 +0100 Subject: [PATCH 016/144] http: Serve placeholder index.html when no STATIC_DIR set --- MANIFEST.in | 1 + mopidy/frontends/http/actor.py | 21 +++--- mopidy/frontends/http/index.html | 111 +++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 mopidy/frontends/http/index.html diff --git a/MANIFEST.in b/MANIFEST.in index f3723ecd..476cd5c1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,6 +4,7 @@ include LICENSE include MANIFEST.in include data/mopidy.desktop include mopidy/backends/spotify/spotify_appkey.key +include mopidy/frontends/http/index.html include pylintrc recursive-include docs * prune docs/_build diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index 5c997f79..7eed0937 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -1,4 +1,5 @@ import logging +import os import pykka @@ -44,22 +45,24 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): root.api = api.ApiResource(self.core) root.ws = ws.WebSocketResource() + if settings.HTTP_SERVER_STATIC_DIR: + static_dir = settings.HTTP_SERVER_STATIC_DIR + else: + static_dir = os.path.dirname(__file__) + logger.debug(u'HTTP server will serve "%s" at /', static_dir) + config = { + '/': { + 'tools.staticdir.on': True, + 'tools.staticdir.index': 'index.html', + 'tools.staticdir.dir': static_dir, + }, '/ws': { 'tools.websocket.on': True, 'tools.websocket.handler_cls': ws.WebSocketHandler, }, } - if settings.HTTP_SERVER_STATIC_DIR: - logger.debug(u'HTTP server will serve "%s" at /', - settings.HTTP_SERVER_STATIC_DIR) - config['/'] = { - 'tools.staticdir.on': True, - 'tools.staticdir.index': 'index.html', - 'tools.staticdir.dir': settings.HTTP_SERVER_STATIC_DIR, - } - return cherrypy.tree.mount(root, '/', config) def _setup_logging(self, app): diff --git a/mopidy/frontends/http/index.html b/mopidy/frontends/http/index.html new file mode 100644 index 00000000..f3f0b208 --- /dev/null +++ b/mopidy/frontends/http/index.html @@ -0,0 +1,111 @@ + + + + + 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 here, change the setting + HTTP_SERVER_STATIC_DIR to point to the directory containing your + content. This can be used to host a web based Mopidy client here.

+ +

Even if you host your own content on the root of the web server, + you'll always have the following services available.

+
+ +
+

Web service

+ +

Mopidy makes it's API available for use over HTTP at + /api/. The service tries to be RESTful. It serves and + eats JSON data.

+
+ +
+

WebSocket endpoint

+ +

Mopidy has a WebSocket endpoint at /ws/. You can + use WebSockets to get notified about events happening in Mopidy. The + alternative would be to regularly poll the conventional web service for + updates.

+ +

To connect to the endpoint from a browser with WebSocket support, + simply enter the following JavaScript code in the browser's console:

+ +
var ws = new WebSocket("ws://myhost:myport/ws/');
+ws.onmessage = function (event) {
+  console.log("Incoming message: ", event.data);
+};
+ws.send("Message to the server, ahoy!");
+
+ +
+

Documentation

+ +

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

+
+ + From 5458c1271fc720b85f120308c931224532356cb5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 9 Nov 2012 14:52:46 +0100 Subject: [PATCH 017/144] http: Switch to HTTP method dispatcher for /api --- mopidy/frontends/http/actor.py | 3 +++ mopidy/frontends/http/api.py | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index 7eed0937..8678d8b0 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -57,6 +57,9 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): 'tools.staticdir.index': 'index.html', 'tools.staticdir.dir': static_dir, }, + '/api': { + 'request.dispatch': cherrypy.dispatch.MethodDispatcher(), + }, '/ws': { 'tools.websocket.on': True, 'tools.websocket.handler_cls': ws.WebSocketHandler, diff --git a/mopidy/frontends/http/api.py b/mopidy/frontends/http/api.py index b414c60c..0003f06a 100644 --- a/mopidy/frontends/http/api.py +++ b/mopidy/frontends/http/api.py @@ -7,12 +7,13 @@ except ImportError as import_error: class ApiResource(object): + exposed = True + def __init__(self, core): self.core = core - @cherrypy.expose @cherrypy.tools.json_out() - def index(self): + def GET(self): playback_state = self.core.playback.state.get() track = self.core.playback.current_track.get() if track: From 86e0eff21db537d8974b23c8ed066fc635becba6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 10 Nov 2012 20:57:18 +0100 Subject: [PATCH 018/144] http: Extend player resource, add tracklist and playlists resources --- mopidy/frontends/http/api.py | 94 ++++++++++++++++++++++-- tests/frontends/http/__init__.py | 0 tests/frontends/http/api_test.py | 118 +++++++++++++++++++++++++++++++ tests/frontends/http/ws_test.py | 5 ++ 4 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 tests/frontends/http/__init__.py create mode 100644 tests/frontends/http/api_test.py create mode 100644 tests/frontends/http/ws_test.py diff --git a/mopidy/frontends/http/api.py b/mopidy/frontends/http/api.py index 0003f06a..66caeb1a 100644 --- a/mopidy/frontends/http/api.py +++ b/mopidy/frontends/http/api.py @@ -11,14 +11,96 @@ class ApiResource(object): def __init__(self, core): self.core = core + self.player = PlayerResource(core) + self.tracklist = TrackListResource(core) + self.playlists = PlaylistsResource(core) @cherrypy.tools.json_out() def GET(self): - playback_state = self.core.playback.state.get() - track = self.core.playback.current_track.get() - if track: - track = track.serialize() return { - 'playback_state': playback_state, - 'current_track': track, + 'resources': { + 'player': { + 'href': '/api/player/', + }, + 'tracklist': { + 'href': '/api/tracklist/', + }, + 'playlists': { + 'href': '/api/playlists/', + }, + } + } + + +class PlayerResource(object): + exposed = True + + def __init__(self, core): + self.core = core + + @cherrypy.tools.json_out() + def GET(self): + futures = { + 'state': self.core.playback.state, + 'current_track': self.core.playback.current_track, + 'consume': self.core.playback.consume, + 'random': self.core.playback.random, + 'repeat': self.core.playback.repeat, + 'single': self.core.playback.single, + 'volume': self.core.playback.volume, + 'time_position': self.core.playback.time_position, + } + current_track = futures['current_track'].get() + if current_track: + current_track = current_track.serialize() + return { + 'properties': { + 'state': futures['state'].get(), + 'currentTrack': current_track, + 'consume': futures['consume'].get(), + 'random': futures['random'].get(), + 'repeat': futures['repeat'].get(), + 'single': futures['single'].get(), + 'volume': futures['volume'].get(), + 'timePosition': futures['time_position'].get(), + } + } + + +class TrackListResource(object): + exposed = True + + def __init__(self, core): + self.core = core + + @cherrypy.tools.json_out() + def GET(self): + futures = { + 'cp_tracks': self.core.current_playlist.cp_tracks, + 'current_cp_track': self.core.playback.current_cp_track, + } + cp_tracks = futures['cp_tracks'].get() + tracks = [] + for cp_track in cp_tracks: + track = cp_track.track.serialize() + track['cpid'] = cp_track.cpid + tracks.append(track) + current_cp_track = futures['current_cp_track'].get() + return { + 'currentTrackCpid': current_cp_track and current_cp_track.cpid, + 'tracks': tracks, + } + + +class PlaylistsResource(object): + exposed = True + + def __init__(self, core): + self.core = core + + @cherrypy.tools.json_out() + def GET(self): + playlists = self.core.stored_playlists.playlists.get() + return { + 'playlists': [p.serialize() for p in playlists], } 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/api_test.py b/tests/frontends/http/api_test.py new file mode 100644 index 00000000..14f94773 --- /dev/null +++ b/tests/frontends/http/api_test.py @@ -0,0 +1,118 @@ +import pykka + +from tests import unittest + +from mopidy import core +from mopidy.backends import dummy +from mopidy.frontends.http import api +from mopidy.models import Track + + +class ApiResourceTest(unittest.TestCase): + def setUp(self): + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() + self.api = api.ApiResource(core=self.core) + + self.core.stored_playlists.create('x') + self.core.stored_playlists.create('y') + self.core.stored_playlists.create('z') + self.core.current_playlist.append([ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + Track(uri='dummy:c'), + ]) + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + def test_api_get_returns_list_of_resources(self): + result = self.api.GET() + + self.assertIn('resources', result) + + self.assertIn('player', result['resources']) + self.assertEquals('/api/player/', + result['resources']['player']['href']) + + self.assertIn('tracklist', result['resources']) + self.assertEquals('/api/tracklist/', + result['resources']['tracklist']['href']) + + self.assertIn('playlists', result['resources']) + self.assertEquals('/api/playlists/', + result['resources']['playlists']['href']) + + def test_player_get_returns_playback_properties(self): + result = self.api.player.GET() + + self.assertIn('properties', result) + + self.assertIn('state', result['properties']) + self.assertEqual('stopped', result['properties']['state']) + + self.assertIn('currentTrack', result['properties']) + self.assertEqual(None, result['properties']['currentTrack']) + + self.assertIn('consume', result['properties']) + self.assertEqual(False, result['properties']['consume']) + + self.assertIn('random', result['properties']) + self.assertEqual(False, result['properties']['random']) + + self.assertIn('repeat', result['properties']) + self.assertEqual(False, result['properties']['repeat']) + + self.assertIn('single', result['properties']) + self.assertEqual(False, result['properties']['single']) + + self.assertIn('volume', result['properties']) + self.assertEqual(None, result['properties']['volume']) + + self.assertIn('timePosition', result['properties']) + self.assertEqual(0, result['properties']['timePosition']) + + def test_player_state_changes_when_playing(self): + self.core.playback.play() + + result = self.api.player.GET() + + self.assertEqual('playing', result['properties']['state']) + + def test_player_volume_changes(self): + self.core.playback.volume = 37 + + result = self.api.player.GET() + + self.assertEqual(37, result['properties']['volume']) + + def test_tracklist_returns_current_playlist(self): + result = self.api.tracklist.GET() + + self.assertIn('tracks', result) + self.assertEqual(3, len(result['tracks'])) + + self.assertEqual('dummy:a', result['tracks'][0]['uri']) + self.assertEqual(0, result['tracks'][0]['cpid']) + + self.assertEqual('dummy:b', result['tracks'][1]['uri']) + self.assertEqual(1, result['tracks'][1]['cpid']) + + self.assertEqual('dummy:c', result['tracks'][2]['uri']) + self.assertEqual(2, result['tracks'][2]['cpid']) + + def test_tracklist_includes_current_track(self): + self.core.playback.play() + + result = self.api.tracklist.GET() + + self.assertIn('currentTrackCpid', result) + self.assertEqual(0, result['currentTrackCpid']) + + def test_playlists_returns_stored_playlists(self): + result = self.api.playlists.GET() + + self.assertIn('playlists', result) + self.assertEqual('x', result['playlists'][0]['name']) + self.assertEqual('y', result['playlists'][1]['name']) + self.assertEqual('z', result['playlists'][2]['name']) diff --git a/tests/frontends/http/ws_test.py b/tests/frontends/http/ws_test.py new file mode 100644 index 00000000..0615052e --- /dev/null +++ b/tests/frontends/http/ws_test.py @@ -0,0 +1,5 @@ +from tests import unittest + + +class WebSocketsTest(unittest.TestCase): + pass # TODO From 90ca6a786a32a01763ea9649f6900f06ba8b4d44 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 10 Nov 2012 21:30:11 +0100 Subject: [PATCH 019/144] http: Add pip requirements file. Make Travis use it. --- .travis.yml | 1 + requirements/http.txt | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 requirements/http.txt 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/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 From 0fc0b4b1efecf3ce35500340e73541eec8a80461 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 11 Nov 2012 19:13:15 +0100 Subject: [PATCH 020/144] http: Don't build almost the same dict twice --- mopidy/frontends/http/api.py | 37 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 25 deletions(-) diff --git a/mopidy/frontends/http/api.py b/mopidy/frontends/http/api.py index 66caeb1a..1c0aa85b 100644 --- a/mopidy/frontends/http/api.py +++ b/mopidy/frontends/http/api.py @@ -40,31 +40,21 @@ class PlayerResource(object): @cherrypy.tools.json_out() def GET(self): - futures = { + properties = { 'state': self.core.playback.state, - 'current_track': self.core.playback.current_track, + 'currentTrack': self.core.playback.current_track, 'consume': self.core.playback.consume, 'random': self.core.playback.random, 'repeat': self.core.playback.repeat, 'single': self.core.playback.single, 'volume': self.core.playback.volume, - 'time_position': self.core.playback.time_position, - } - current_track = futures['current_track'].get() - if current_track: - current_track = current_track.serialize() - return { - 'properties': { - 'state': futures['state'].get(), - 'currentTrack': current_track, - 'consume': futures['consume'].get(), - 'random': futures['random'].get(), - 'repeat': futures['repeat'].get(), - 'single': futures['single'].get(), - 'volume': futures['volume'].get(), - 'timePosition': futures['time_position'].get(), - } + 'timePosition': self.core.playback.time_position, } + for key, value in properties.items(): + properties[key] = value.get() + if properties['currentTrack']: + properties['currentTrack'] = properties['currentTrack'].serialize() + return {'properties': properties} class TrackListResource(object): @@ -75,17 +65,14 @@ class TrackListResource(object): @cherrypy.tools.json_out() def GET(self): - futures = { - 'cp_tracks': self.core.current_playlist.cp_tracks, - 'current_cp_track': self.core.playback.current_cp_track, - } - cp_tracks = futures['cp_tracks'].get() + cp_tracks_future = self.core.current_playlist.cp_tracks + current_cp_track_future = self.core.playback.current_cp_track tracks = [] - for cp_track in cp_tracks: + for cp_track in cp_tracks_future.get(): track = cp_track.track.serialize() track['cpid'] = cp_track.cpid tracks.append(track) - current_cp_track = futures['current_cp_track'].get() + current_cp_track = current_cp_track_future.get() return { 'currentTrackCpid': current_cp_track and current_cp_track.cpid, 'tracks': tracks, From 1d14740a661488ab34b2c97b6aa86b9591b7289e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 11 Nov 2012 20:13:20 +0100 Subject: [PATCH 021/144] http: Show incoming WebSocket messages on the placeholder page --- mopidy/frontends/http/index.html | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/http/index.html b/mopidy/frontends/http/index.html index f3f0b208..37823577 100644 --- a/mopidy/frontends/http/index.html +++ b/mopidy/frontends/http/index.html @@ -8,7 +8,7 @@ background: #e8ecef; color: #555; font-family: "Droid Serif", Georgia, "Times New Roman", Palatino, - "Hoefler Text", Baskerville, serif;; + "Hoefler Text", Baskerville, serif; font-size: 150%; line-height: 1.4em; } @@ -53,6 +53,10 @@ .box.focus a { color: #e8ecef; } + #ws-console { + height: 200px; + overflow: auto; + } @@ -99,6 +103,10 @@ ws.onmessage = function (event) { console.log("Incoming message: ", event.data); }; ws.send("Message to the server, ahoy!"); + +

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

+ +

     
 
     
@@ -107,5 +115,16 @@ ws.send("Message to the server, ahoy!");

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

+ + From df4d7cd4c987f758289654f7b1bbe5c17e9e7d0a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 22:11:45 +0100 Subject: [PATCH 022/144] http: Update to use unicode literals and not cause warnings with flake8 --- mopidy/frontends/http/actor.py | 25 ++++++++++++++----------- mopidy/frontends/http/api.py | 2 ++ mopidy/frontends/http/ws.py | 13 +++++++++---- tests/frontends/http/api_test.py | 14 ++++++++------ tests/frontends/http/ws_test.py | 2 ++ 5 files changed, 35 insertions(+), 21 deletions(-) diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index 8678d8b0..f6a9a441 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging import os @@ -31,8 +33,8 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): def _setup_server(self): cherrypy.config.update({ 'engine.autoreload_on': False, - 'server.socket_host': - settings.HTTP_SERVER_HOSTNAME.encode('utf-8'), + 'server.socket_host': ( + settings.HTTP_SERVER_HOSTNAME.encode('utf-8')), 'server.socket_port': settings.HTTP_SERVER_PORT, }) @@ -49,18 +51,18 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): static_dir = settings.HTTP_SERVER_STATIC_DIR else: static_dir = os.path.dirname(__file__) - logger.debug(u'HTTP server will serve "%s" at /', static_dir) + logger.debug('HTTP server will serve "%s" at /', static_dir) config = { - '/': { + b'/': { 'tools.staticdir.on': True, 'tools.staticdir.index': 'index.html', 'tools.staticdir.dir': static_dir, }, - '/api': { + b'/api': { 'request.dispatch': cherrypy.dispatch.MethodDispatcher(), }, - '/ws': { + b'/ws': { 'tools.websocket.on': True, 'tools.websocket.handler_cls': ws.WebSocketHandler, }, @@ -77,17 +79,18 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): app.log.error_log.setLevel(logging.NOTSET) def on_start(self): - logger.debug(u'Starting HTTP server') + logger.debug('Starting HTTP server') cherrypy.engine.start() - logger.info(u'HTTP server running at %s', cherrypy.server.base()) + logger.info('HTTP server running at %s', cherrypy.server.base()) def on_stop(self): - logger.debug(u'Stopping HTTP server') + logger.debug('Stopping HTTP server') cherrypy.engine.exit() - logger.info(u'Stopped HTTP server') + logger.info('Stopped HTTP server') def playback_state_changed(self, old_state, new_state): - cherrypy.engine.publish('websocket-broadcast', + cherrypy.engine.publish( + 'websocket-broadcast', TextMessage('playback_state_changed: %s -> %s' % ( old_state, new_state))) diff --git a/mopidy/frontends/http/api.py b/mopidy/frontends/http/api.py index caaf69e2..f5a78f99 100644 --- a/mopidy/frontends/http/api.py +++ b/mopidy/frontends/http/api.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from mopidy import exceptions try: diff --git a/mopidy/frontends/http/ws.py b/mopidy/frontends/http/ws.py index 53ad651d..98fe8562 100644 --- a/mopidy/frontends/http/ws.py +++ b/mopidy/frontends/http/ws.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import logging from mopidy import exceptions @@ -15,23 +17,26 @@ logger = logging.getLogger('mopidy.frontends.http') class WebSocketResource(object): @cherrypy.expose def index(self): - logger.debug(u'WebSocket handler created') + logger.debug('WebSocket handler created') class WebSocketHandler(WebSocket): def opened(self): remote = cherrypy.request.remote - logger.debug(u'New WebSocket connection from %s:%d', + logger.debug( + 'New WebSocket connection from %s:%d', remote.ip, remote.port) def closed(self, code, reason=None): remote = cherrypy.request.remote - logger.debug(u'Closed WebSocket connection from %s:%d ' + logger.debug( + 'Closed WebSocket connection from %s:%d ' 'with code %s and reason %r', remote.ip, remote.port, code, reason) def received_message(self, message): remote = cherrypy.request.remote - logger.debug(u'Received WebSocket message from %s:%d: %s', + logger.debug( + 'Received WebSocket message from %s:%d: %s', remote.ip, remote.port, message) # This is where we would handle incoming messages from the clients diff --git a/tests/frontends/http/api_test.py b/tests/frontends/http/api_test.py index 88aa5682..cd77b923 100644 --- a/tests/frontends/http/api_test.py +++ b/tests/frontends/http/api_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + import pykka from tests import unittest @@ -32,16 +34,16 @@ class ApiResourceTest(unittest.TestCase): self.assertIn('resources', result) self.assertIn('player', result['resources']) - self.assertEquals('/api/player/', - result['resources']['player']['href']) + self.assertEquals( + '/api/player/', result['resources']['player']['href']) self.assertIn('tracklist', result['resources']) - self.assertEquals('/api/tracklist/', - result['resources']['tracklist']['href']) + self.assertEquals( + '/api/tracklist/', result['resources']['tracklist']['href']) self.assertIn('playlists', result['resources']) - self.assertEquals('/api/playlists/', - result['resources']['playlists']['href']) + self.assertEquals( + '/api/playlists/', result['resources']['playlists']['href']) def test_player_get_returns_playback_properties(self): result = self.api.player.GET() diff --git a/tests/frontends/http/ws_test.py b/tests/frontends/http/ws_test.py index 0615052e..0f0f6ff3 100644 --- a/tests/frontends/http/ws_test.py +++ b/tests/frontends/http/ws_test.py @@ -1,3 +1,5 @@ +from __future__ import unicode_literals + from tests import unittest From 97ca863c5d633e4e3b0dd6fa3b6d2d8f29c0c29c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 16 Nov 2012 22:35:00 +0100 Subject: [PATCH 023/144] http: Pass core to WebSocket handler --- mopidy/frontends/http/actor.py | 2 +- mopidy/frontends/http/ws.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index f6a9a441..181eb93c 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -45,7 +45,7 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): def _create_app(self): root = RootResource() root.api = api.ApiResource(self.core) - root.ws = ws.WebSocketResource() + root.ws = ws.WebSocketResource(self.core) if settings.HTTP_SERVER_STATIC_DIR: static_dir = settings.HTTP_SERVER_STATIC_DIR diff --git a/mopidy/frontends/http/ws.py b/mopidy/frontends/http/ws.py index 98fe8562..581eb849 100644 --- a/mopidy/frontends/http/ws.py +++ b/mopidy/frontends/http/ws.py @@ -15,9 +15,13 @@ logger = logging.getLogger('mopidy.frontends.http') class WebSocketResource(object): + def __init__(self, core): + self.core = core + @cherrypy.expose def index(self): logger.debug('WebSocket handler created') + cherrypy.request.ws_handler.core = self.core class WebSocketHandler(WebSocket): @@ -40,3 +44,6 @@ class WebSocketHandler(WebSocket): 'Received WebSocket message from %s:%d: %s', remote.ip, remote.port, message) # This is where we would handle incoming messages from the clients + + # This is just for demonstration purposes + self.send('Playback state: %s' % self.core.playback.state.get()) From 02360ae9c0d553e8ac172b241b70a0518d8dbe3a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 18 Nov 2012 18:27:50 +0100 Subject: [PATCH 024/144] http: Move static files to its own dir --- mopidy/frontends/http/actor.py | 2 +- mopidy/frontends/http/{ => data}/index.html | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename mopidy/frontends/http/{ => data}/index.html (100%) diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index 181eb93c..b0697409 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -50,7 +50,7 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): if settings.HTTP_SERVER_STATIC_DIR: static_dir = settings.HTTP_SERVER_STATIC_DIR else: - static_dir = os.path.dirname(__file__) + static_dir = os.path.join(os.path.dirname(__file__), 'data') logger.debug('HTTP server will serve "%s" at /', static_dir) config = { diff --git a/mopidy/frontends/http/index.html b/mopidy/frontends/http/data/index.html similarity index 100% rename from mopidy/frontends/http/index.html rename to mopidy/frontends/http/data/index.html From e87a73298cf86814e4143f8f4885e3b7c4b3b18c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 18 Nov 2012 18:31:22 +0100 Subject: [PATCH 025/144] http: Include data dir in distribution packages --- MANIFEST.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 476cd5c1..6a64cb9a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,10 +4,10 @@ include LICENSE include MANIFEST.in include data/mopidy.desktop include mopidy/backends/spotify/spotify_appkey.key -include mopidy/frontends/http/index.html 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 * From fd47d19ff4be6ee90c51ccf02a2f9611e1414c7c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 18 Nov 2012 18:31:49 +0100 Subject: [PATCH 026/144] http: Bundle jQuery --- mopidy/frontends/http/data/index.html | 4 +--- mopidy/frontends/http/data/jquery-1.8.3.min.js | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 mopidy/frontends/http/data/jquery-1.8.3.min.js diff --git a/mopidy/frontends/http/data/index.html b/mopidy/frontends/http/data/index.html index 37823577..a426b4d5 100644 --- a/mopidy/frontends/http/data/index.html +++ b/mopidy/frontends/http/data/index.html @@ -115,9 +115,7 @@ ws.send("Message to the server, ahoy!");

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

- + From 96a31adf637635e6279f048fe12a2a242eb7b463 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 28 Nov 2012 19:17:46 +0100 Subject: [PATCH 065/144] http: Use CSS file from mopidy.com --- mopidy/frontends/http/data/index.html | 56 +-------------------- mopidy/frontends/http/data/mopidy.css | 71 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 55 deletions(-) create mode 100644 mopidy/frontends/http/data/mopidy.css diff --git a/mopidy/frontends/http/data/index.html b/mopidy/frontends/http/data/index.html index f706a317..c3a4199b 100644 --- a/mopidy/frontends/http/data/index.html +++ b/mopidy/frontends/http/data/index.html @@ -3,61 +3,7 @@ Mopidy HTTP frontend - +
diff --git a/mopidy/frontends/http/data/mopidy.css b/mopidy/frontends/http/data/mopidy.css new file mode 100644 index 00000000..7944a53a --- /dev/null +++ b/mopidy/frontends/http/data/mopidy.css @@ -0,0 +1,71 @@ +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; +} From c1e60ba3787ca227150987c459accd2853091f78 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 28 Nov 2012 19:18:13 +0100 Subject: [PATCH 066/144] http: Add /mopidy/ static dir that is always available --- mopidy/frontends/http/actor.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index 3f7fa1aa..65cf9445 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -54,12 +54,19 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): 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') + config = { b'/': { 'tools.staticdir.on': True, 'tools.staticdir.index': 'index.html', 'tools.staticdir.dir': static_dir, }, + 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, From 67c92ceab99d1a66962d4223e366cb80a52032ef Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 28 Nov 2012 19:19:06 +0100 Subject: [PATCH 067/144] http: Move resource details to /mopidy/ page --- mopidy/frontends/http/data/index.html | 56 ++++--------------------- mopidy/frontends/http/data/mopidy.html | 58 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 47 deletions(-) create mode 100644 mopidy/frontends/http/data/mopidy.html diff --git a/mopidy/frontends/http/data/index.html b/mopidy/frontends/http/data/index.html index c3a4199b..37885eef 100644 --- a/mopidy/frontends/http/data/index.html +++ b/mopidy/frontends/http/data/index.html @@ -3,65 +3,27 @@ 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.

+ about Mopidy, please visit + www.mopidy.com.

Static content serving

-

To see your own content here, change the setting - HTTP_SERVER_STATIC_DIR to point to the directory containing your - content. This can be used to host a web based Mopidy client here.

+

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.

-

Even if you host your own content on the root of the web server, - you'll always have the following services available.

+

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

- -
-

WebSocket endpoint

- -

Mopidy has a WebSocket endpoint at /mopidy/ws/. You can - use WebSockets to get notified about events happening in Mopidy. The - alternative would be to regularly poll the conventional web service for - updates.

- -

To connect to the endpoint from a browser with WebSocket support, - simply enter the following JavaScript code in the browser's console:

- -
var ws = new WebSocket("ws://myhost:myport/mopidy/ws/');
-ws.onmessage = function (event) {
-  console.log("Incoming message: ", event.data);
-};
-ws.send("Message to the server, ahoy!");
- -

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

- -

-    
- -
-

Documentation

- -

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

-
- - diff --git a/mopidy/frontends/http/data/mopidy.html b/mopidy/frontends/http/data/mopidy.html new file mode 100644 index 00000000..81fe445a --- /dev/null +++ b/mopidy/frontends/http/data/mopidy.html @@ -0,0 +1,58 @@ + + + + + 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.

+
+ + + + From df6df5b46aac128d2ac24ed40a373a0936c8439c Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 28 Nov 2012 19:35:17 +0100 Subject: [PATCH 068/144] http: Minor HTML/CSS tweaks --- mopidy/frontends/http/data/index.html | 2 +- mopidy/frontends/http/data/mopidy.css | 4 ++++ mopidy/frontends/http/data/mopidy.html | 8 +------- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/mopidy/frontends/http/data/index.html b/mopidy/frontends/http/data/index.html index 37885eef..85d3d331 100644 --- a/mopidy/frontends/http/data/index.html +++ b/mopidy/frontends/http/data/index.html @@ -3,7 +3,7 @@ Mopidy HTTP frontend - +
diff --git a/mopidy/frontends/http/data/mopidy.css b/mopidy/frontends/http/data/mopidy.css index 7944a53a..c5042769 100644 --- a/mopidy/frontends/http/data/mopidy.css +++ b/mopidy/frontends/http/data/mopidy.css @@ -69,3 +69,7 @@ code, pre { .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 index 81fe445a..64501830 100644 --- a/mopidy/frontends/http/data/mopidy.html +++ b/mopidy/frontends/http/data/mopidy.html @@ -3,13 +3,7 @@ Mopidy HTTP frontend - - +
From bc151b32a76e7b52d6ffb6120ac04bd9455b7bcb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 28 Nov 2012 19:42:34 +0100 Subject: [PATCH 069/144] http: Rewrite example to avoid jQuery use --- mopidy/frontends/http/data/jquery-1.8.3.min.js | 2 -- mopidy/frontends/http/data/mopidy.html | 15 +++++++-------- 2 files changed, 7 insertions(+), 10 deletions(-) delete mode 100644 mopidy/frontends/http/data/jquery-1.8.3.min.js diff --git a/mopidy/frontends/http/data/jquery-1.8.3.min.js b/mopidy/frontends/http/data/jquery-1.8.3.min.js deleted file mode 100644 index 83589daa..00000000 --- a/mopidy/frontends/http/data/jquery-1.8.3.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! jQuery v1.8.3 jquery.com | jquery.org/license */ -(function(e,t){function _(e){var t=M[e]={};return v.each(e.split(y),function(e,n){t[n]=!0}),t}function H(e,n,r){if(r===t&&e.nodeType===1){var i="data-"+n.replace(P,"-$1").toLowerCase();r=e.getAttribute(i);if(typeof r=="string"){try{r=r==="true"?!0:r==="false"?!1:r==="null"?null:+r+""===r?+r:D.test(r)?v.parseJSON(r):r}catch(s){}v.data(e,n,r)}else r=t}return r}function B(e){var t;for(t in e){if(t==="data"&&v.isEmptyObject(e[t]))continue;if(t!=="toJSON")return!1}return!0}function et(){return!1}function tt(){return!0}function ut(e){return!e||!e.parentNode||e.parentNode.nodeType===11}function at(e,t){do e=e[t];while(e&&e.nodeType!==1);return e}function ft(e,t,n){t=t||0;if(v.isFunction(t))return v.grep(e,function(e,r){var i=!!t.call(e,r,e);return i===n});if(t.nodeType)return v.grep(e,function(e,r){return e===t===n});if(typeof t=="string"){var r=v.grep(e,function(e){return e.nodeType===1});if(it.test(t))return v.filter(t,r,!n);t=v.filter(t,r)}return v.grep(e,function(e,r){return v.inArray(e,t)>=0===n})}function lt(e){var t=ct.split("|"),n=e.createDocumentFragment();if(n.createElement)while(t.length)n.createElement(t.pop());return n}function Lt(e,t){return e.getElementsByTagName(t)[0]||e.appendChild(e.ownerDocument.createElement(t))}function At(e,t){if(t.nodeType!==1||!v.hasData(e))return;var n,r,i,s=v._data(e),o=v._data(t,s),u=s.events;if(u){delete o.handle,o.events={};for(n in u)for(r=0,i=u[n].length;r").appendTo(i.body),n=t.css("display");t.remove();if(n==="none"||n===""){Pt=i.body.appendChild(Pt||v.extend(i.createElement("iframe"),{frameBorder:0,width:0,height:0}));if(!Ht||!Pt.createElement)Ht=(Pt.contentWindow||Pt.contentDocument).document,Ht.write(""),Ht.close();t=Ht.body.appendChild(Ht.createElement(e)),n=Dt(t,"display"),i.body.removeChild(Pt)}return Wt[e]=n,n}function fn(e,t,n,r){var i;if(v.isArray(t))v.each(t,function(t,i){n||sn.test(e)?r(e,i):fn(e+"["+(typeof i=="object"?t:"")+"]",i,n,r)});else if(!n&&v.type(t)==="object")for(i in t)fn(e+"["+i+"]",t[i],n,r);else r(e,t)}function Cn(e){return function(t,n){typeof t!="string"&&(n=t,t="*");var r,i,s,o=t.toLowerCase().split(y),u=0,a=o.length;if(v.isFunction(n))for(;u)[^>]*$|#([\w\-]*)$)/,E=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,S=/^[\],:{}\s]*$/,x=/(?:^|:|,)(?:\s*\[)+/g,T=/\\(?:["\\\/bfnrt]|u[\da-fA-F]{4})/g,N=/"[^"\\\r\n]*"|true|false|null|-?(?:\d\d*\.|)\d+(?:[eE][\-+]?\d+|)/g,C=/^-ms-/,k=/-([\da-z])/gi,L=function(e,t){return(t+"").toUpperCase()},A=function(){i.addEventListener?(i.removeEventListener("DOMContentLoaded",A,!1),v.ready()):i.readyState==="complete"&&(i.detachEvent("onreadystatechange",A),v.ready())},O={};v.fn=v.prototype={constructor:v,init:function(e,n,r){var s,o,u,a;if(!e)return this;if(e.nodeType)return this.context=this[0]=e,this.length=1,this;if(typeof e=="string"){e.charAt(0)==="<"&&e.charAt(e.length-1)===">"&&e.length>=3?s=[null,e,null]:s=w.exec(e);if(s&&(s[1]||!n)){if(s[1])return n=n instanceof v?n[0]:n,a=n&&n.nodeType?n.ownerDocument||n:i,e=v.parseHTML(s[1],a,!0),E.test(s[1])&&v.isPlainObject(n)&&this.attr.call(e,n,!0),v.merge(this,e);o=i.getElementById(s[2]);if(o&&o.parentNode){if(o.id!==s[2])return r.find(e);this.length=1,this[0]=o}return this.context=i,this.selector=e,this}return!n||n.jquery?(n||r).find(e):this.constructor(n).find(e)}return v.isFunction(e)?r.ready(e):(e.selector!==t&&(this.selector=e.selector,this.context=e.context),v.makeArray(e,this))},selector:"",jquery:"1.8.3",length:0,size:function(){return this.length},toArray:function(){return l.call(this)},get:function(e){return e==null?this.toArray():e<0?this[this.length+e]:this[e]},pushStack:function(e,t,n){var r=v.merge(this.constructor(),e);return r.prevObject=this,r.context=this.context,t==="find"?r.selector=this.selector+(this.selector?" ":"")+n:t&&(r.selector=this.selector+"."+t+"("+n+")"),r},each:function(e,t){return v.each(this,e,t)},ready:function(e){return v.ready.promise().done(e),this},eq:function(e){return e=+e,e===-1?this.slice(e):this.slice(e,e+1)},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},slice:function(){return this.pushStack(l.apply(this,arguments),"slice",l.call(arguments).join(","))},map:function(e){return this.pushStack(v.map(this,function(t,n){return e.call(t,n,t)}))},end:function(){return this.prevObject||this.constructor(null)},push:f,sort:[].sort,splice:[].splice},v.fn.init.prototype=v.fn,v.extend=v.fn.extend=function(){var e,n,r,i,s,o,u=arguments[0]||{},a=1,f=arguments.length,l=!1;typeof u=="boolean"&&(l=u,u=arguments[1]||{},a=2),typeof u!="object"&&!v.isFunction(u)&&(u={}),f===a&&(u=this,--a);for(;a0)return;r.resolveWith(i,[v]),v.fn.trigger&&v(i).trigger("ready").off("ready")},isFunction:function(e){return v.type(e)==="function"},isArray:Array.isArray||function(e){return v.type(e)==="array"},isWindow:function(e){return e!=null&&e==e.window},isNumeric:function(e){return!isNaN(parseFloat(e))&&isFinite(e)},type:function(e){return e==null?String(e):O[h.call(e)]||"object"},isPlainObject:function(e){if(!e||v.type(e)!=="object"||e.nodeType||v.isWindow(e))return!1;try{if(e.constructor&&!p.call(e,"constructor")&&!p.call(e.constructor.prototype,"isPrototypeOf"))return!1}catch(n){return!1}var r;for(r in e);return r===t||p.call(e,r)},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},error:function(e){throw new Error(e)},parseHTML:function(e,t,n){var r;return!e||typeof e!="string"?null:(typeof t=="boolean"&&(n=t,t=0),t=t||i,(r=E.exec(e))?[t.createElement(r[1])]:(r=v.buildFragment([e],t,n?null:[]),v.merge([],(r.cacheable?v.clone(r.fragment):r.fragment).childNodes)))},parseJSON:function(t){if(!t||typeof t!="string")return null;t=v.trim(t);if(e.JSON&&e.JSON.parse)return e.JSON.parse(t);if(S.test(t.replace(T,"@").replace(N,"]").replace(x,"")))return(new Function("return "+t))();v.error("Invalid JSON: "+t)},parseXML:function(n){var r,i;if(!n||typeof n!="string")return null;try{e.DOMParser?(i=new DOMParser,r=i.parseFromString(n,"text/xml")):(r=new ActiveXObject("Microsoft.XMLDOM"),r.async="false",r.loadXML(n))}catch(s){r=t}return(!r||!r.documentElement||r.getElementsByTagName("parsererror").length)&&v.error("Invalid XML: "+n),r},noop:function(){},globalEval:function(t){t&&g.test(t)&&(e.execScript||function(t){e.eval.call(e,t)})(t)},camelCase:function(e){return e.replace(C,"ms-").replace(k,L)},nodeName:function(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()},each:function(e,n,r){var i,s=0,o=e.length,u=o===t||v.isFunction(e);if(r){if(u){for(i in e)if(n.apply(e[i],r)===!1)break}else for(;s0&&e[0]&&e[a-1]||a===0||v.isArray(e));if(f)for(;u-1)a.splice(n,1),i&&(n<=o&&o--,n<=u&&u--)}),this},has:function(e){return v.inArray(e,a)>-1},empty:function(){return a=[],this},disable:function(){return a=f=n=t,this},disabled:function(){return!a},lock:function(){return f=t,n||c.disable(),this},locked:function(){return!f},fireWith:function(e,t){return t=t||[],t=[e,t.slice?t.slice():t],a&&(!r||f)&&(i?f.push(t):l(t)),this},fire:function(){return c.fireWith(this,arguments),this},fired:function(){return!!r}};return c},v.extend({Deferred:function(e){var t=[["resolve","done",v.Callbacks("once memory"),"resolved"],["reject","fail",v.Callbacks("once memory"),"rejected"],["notify","progress",v.Callbacks("memory")]],n="pending",r={state:function(){return n},always:function(){return i.done(arguments).fail(arguments),this},then:function(){var e=arguments;return v.Deferred(function(n){v.each(t,function(t,r){var s=r[0],o=e[t];i[r[1]](v.isFunction(o)?function(){var e=o.apply(this,arguments);e&&v.isFunction(e.promise)?e.promise().done(n.resolve).fail(n.reject).progress(n.notify):n[s+"With"](this===i?n:this,[e])}:n[s])}),e=null}).promise()},promise:function(e){return e!=null?v.extend(e,r):r}},i={};return r.pipe=r.then,v.each(t,function(e,s){var o=s[2],u=s[3];r[s[1]]=o.add,u&&o.add(function(){n=u},t[e^1][2].disable,t[2][2].lock),i[s[0]]=o.fire,i[s[0]+"With"]=o.fireWith}),r.promise(i),e&&e.call(i,i),i},when:function(e){var t=0,n=l.call(arguments),r=n.length,i=r!==1||e&&v.isFunction(e.promise)?r:0,s=i===1?e:v.Deferred(),o=function(e,t,n){return function(r){t[e]=this,n[e]=arguments.length>1?l.call(arguments):r,n===u?s.notifyWith(t,n):--i||s.resolveWith(t,n)}},u,a,f;if(r>1){u=new Array(r),a=new Array(r),f=new Array(r);for(;t
a",n=p.getElementsByTagName("*"),r=p.getElementsByTagName("a")[0];if(!n||!r||!n.length)return{};s=i.createElement("select"),o=s.appendChild(i.createElement("option")),u=p.getElementsByTagName("input")[0],r.style.cssText="top:1px;float:left;opacity:.5",t={leadingWhitespace:p.firstChild.nodeType===3,tbody:!p.getElementsByTagName("tbody").length,htmlSerialize:!!p.getElementsByTagName("link").length,style:/top/.test(r.getAttribute("style")),hrefNormalized:r.getAttribute("href")==="/a",opacity:/^0.5/.test(r.style.opacity),cssFloat:!!r.style.cssFloat,checkOn:u.value==="on",optSelected:o.selected,getSetAttribute:p.className!=="t",enctype:!!i.createElement("form").enctype,html5Clone:i.createElement("nav").cloneNode(!0).outerHTML!=="<:nav>",boxModel:i.compatMode==="CSS1Compat",submitBubbles:!0,changeBubbles:!0,focusinBubbles:!1,deleteExpando:!0,noCloneEvent:!0,inlineBlockNeedsLayout:!1,shrinkWrapBlocks:!1,reliableMarginRight:!0,boxSizingReliable:!0,pixelPosition:!1},u.checked=!0,t.noCloneChecked=u.cloneNode(!0).checked,s.disabled=!0,t.optDisabled=!o.disabled;try{delete p.test}catch(d){t.deleteExpando=!1}!p.addEventListener&&p.attachEvent&&p.fireEvent&&(p.attachEvent("onclick",h=function(){t.noCloneEvent=!1}),p.cloneNode(!0).fireEvent("onclick"),p.detachEvent("onclick",h)),u=i.createElement("input"),u.value="t",u.setAttribute("type","radio"),t.radioValue=u.value==="t",u.setAttribute("checked","checked"),u.setAttribute("name","t"),p.appendChild(u),a=i.createDocumentFragment(),a.appendChild(p.lastChild),t.checkClone=a.cloneNode(!0).cloneNode(!0).lastChild.checked,t.appendChecked=u.checked,a.removeChild(u),a.appendChild(p);if(p.attachEvent)for(l in{submit:!0,change:!0,focusin:!0})f="on"+l,c=f in p,c||(p.setAttribute(f,"return;"),c=typeof p[f]=="function"),t[l+"Bubbles"]=c;return v(function(){var n,r,s,o,u="padding:0;margin:0;border:0;display:block;overflow:hidden;",a=i.getElementsByTagName("body")[0];if(!a)return;n=i.createElement("div"),n.style.cssText="visibility:hidden;border:0;width:0;height:0;position:static;top:0;margin-top:1px",a.insertBefore(n,a.firstChild),r=i.createElement("div"),n.appendChild(r),r.innerHTML="
t
",s=r.getElementsByTagName("td"),s[0].style.cssText="padding:0;margin:0;border:0;display:none",c=s[0].offsetHeight===0,s[0].style.display="",s[1].style.display="none",t.reliableHiddenOffsets=c&&s[0].offsetHeight===0,r.innerHTML="",r.style.cssText="box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1px;border:1px;display:block;width:4px;margin-top:1%;position:absolute;top:1%;",t.boxSizing=r.offsetWidth===4,t.doesNotIncludeMarginInBodyOffset=a.offsetTop!==1,e.getComputedStyle&&(t.pixelPosition=(e.getComputedStyle(r,null)||{}).top!=="1%",t.boxSizingReliable=(e.getComputedStyle(r,null)||{width:"4px"}).width==="4px",o=i.createElement("div"),o.style.cssText=r.style.cssText=u,o.style.marginRight=o.style.width="0",r.style.width="1px",r.appendChild(o),t.reliableMarginRight=!parseFloat((e.getComputedStyle(o,null)||{}).marginRight)),typeof r.style.zoom!="undefined"&&(r.innerHTML="",r.style.cssText=u+"width:1px;padding:1px;display:inline;zoom:1",t.inlineBlockNeedsLayout=r.offsetWidth===3,r.style.display="block",r.style.overflow="visible",r.innerHTML="
",r.firstChild.style.width="5px",t.shrinkWrapBlocks=r.offsetWidth!==3,n.style.zoom=1),a.removeChild(n),n=r=s=o=null}),a.removeChild(p),n=r=s=o=u=a=p=null,t}();var D=/(?:\{[\s\S]*\}|\[[\s\S]*\])$/,P=/([A-Z])/g;v.extend({cache:{},deletedIds:[],uuid:0,expando:"jQuery"+(v.fn.jquery+Math.random()).replace(/\D/g,""),noData:{embed:!0,object:"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000",applet:!0},hasData:function(e){return e=e.nodeType?v.cache[e[v.expando]]:e[v.expando],!!e&&!B(e)},data:function(e,n,r,i){if(!v.acceptData(e))return;var s,o,u=v.expando,a=typeof n=="string",f=e.nodeType,l=f?v.cache:e,c=f?e[u]:e[u]&&u;if((!c||!l[c]||!i&&!l[c].data)&&a&&r===t)return;c||(f?e[u]=c=v.deletedIds.pop()||v.guid++:c=u),l[c]||(l[c]={},f||(l[c].toJSON=v.noop));if(typeof n=="object"||typeof n=="function")i?l[c]=v.extend(l[c],n):l[c].data=v.extend(l[c].data,n);return s=l[c],i||(s.data||(s.data={}),s=s.data),r!==t&&(s[v.camelCase(n)]=r),a?(o=s[n],o==null&&(o=s[v.camelCase(n)])):o=s,o},removeData:function(e,t,n){if(!v.acceptData(e))return;var r,i,s,o=e.nodeType,u=o?v.cache:e,a=o?e[v.expando]:v.expando;if(!u[a])return;if(t){r=n?u[a]:u[a].data;if(r){v.isArray(t)||(t in r?t=[t]:(t=v.camelCase(t),t in r?t=[t]:t=t.split(" ")));for(i=0,s=t.length;i1,null,!1))},removeData:function(e){return this.each(function(){v.removeData(this,e)})}}),v.extend({queue:function(e,t,n){var r;if(e)return t=(t||"fx")+"queue",r=v._data(e,t),n&&(!r||v.isArray(n)?r=v._data(e,t,v.makeArray(n)):r.push(n)),r||[]},dequeue:function(e,t){t=t||"fx";var n=v.queue(e,t),r=n.length,i=n.shift(),s=v._queueHooks(e,t),o=function(){v.dequeue(e,t)};i==="inprogress"&&(i=n.shift(),r--),i&&(t==="fx"&&n.unshift("inprogress"),delete s.stop,i.call(e,o,s)),!r&&s&&s.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return v._data(e,n)||v._data(e,n,{empty:v.Callbacks("once memory").add(function(){v.removeData(e,t+"queue",!0),v.removeData(e,n,!0)})})}}),v.fn.extend({queue:function(e,n){var r=2;return typeof e!="string"&&(n=e,e="fx",r--),arguments.length1)},removeAttr:function(e){return this.each(function(){v.removeAttr(this,e)})},prop:function(e,t){return v.access(this,v.prop,e,t,arguments.length>1)},removeProp:function(e){return e=v.propFix[e]||e,this.each(function(){try{this[e]=t,delete this[e]}catch(n){}})},addClass:function(e){var t,n,r,i,s,o,u;if(v.isFunction(e))return this.each(function(t){v(this).addClass(e.call(this,t,this.className))});if(e&&typeof e=="string"){t=e.split(y);for(n=0,r=this.length;n=0)r=r.replace(" "+n[s]+" "," ");i.className=e?v.trim(r):""}}}return this},toggleClass:function(e,t){var n=typeof e,r=typeof t=="boolean";return v.isFunction(e)?this.each(function(n){v(this).toggleClass(e.call(this,n,this.className,t),t)}):this.each(function(){if(n==="string"){var i,s=0,o=v(this),u=t,a=e.split(y);while(i=a[s++])u=r?u:!o.hasClass(i),o[u?"addClass":"removeClass"](i)}else if(n==="undefined"||n==="boolean")this.className&&v._data(this,"__className__",this.className),this.className=this.className||e===!1?"":v._data(this,"__className__")||""})},hasClass:function(e){var t=" "+e+" ",n=0,r=this.length;for(;n=0)return!0;return!1},val:function(e){var n,r,i,s=this[0];if(!arguments.length){if(s)return n=v.valHooks[s.type]||v.valHooks[s.nodeName.toLowerCase()],n&&"get"in n&&(r=n.get(s,"value"))!==t?r:(r=s.value,typeof r=="string"?r.replace(R,""):r==null?"":r);return}return i=v.isFunction(e),this.each(function(r){var s,o=v(this);if(this.nodeType!==1)return;i?s=e.call(this,r,o.val()):s=e,s==null?s="":typeof s=="number"?s+="":v.isArray(s)&&(s=v.map(s,function(e){return e==null?"":e+""})),n=v.valHooks[this.type]||v.valHooks[this.nodeName.toLowerCase()];if(!n||!("set"in n)||n.set(this,s,"value")===t)this.value=s})}}),v.extend({valHooks:{option:{get:function(e){var t=e.attributes.value;return!t||t.specified?e.value:e.text}},select:{get:function(e){var t,n,r=e.options,i=e.selectedIndex,s=e.type==="select-one"||i<0,o=s?null:[],u=s?i+1:r.length,a=i<0?u:s?i:0;for(;a=0}),n.length||(e.selectedIndex=-1),n}}},attrFn:{},attr:function(e,n,r,i){var s,o,u,a=e.nodeType;if(!e||a===3||a===8||a===2)return;if(i&&v.isFunction(v.fn[n]))return v(e)[n](r);if(typeof e.getAttribute=="undefined")return v.prop(e,n,r);u=a!==1||!v.isXMLDoc(e),u&&(n=n.toLowerCase(),o=v.attrHooks[n]||(X.test(n)?F:j));if(r!==t){if(r===null){v.removeAttr(e,n);return}return o&&"set"in o&&u&&(s=o.set(e,r,n))!==t?s:(e.setAttribute(n,r+""),r)}return o&&"get"in o&&u&&(s=o.get(e,n))!==null?s:(s=e.getAttribute(n),s===null?t:s)},removeAttr:function(e,t){var n,r,i,s,o=0;if(t&&e.nodeType===1){r=t.split(y);for(;o=0}})});var $=/^(?:textarea|input|select)$/i,J=/^([^\.]*|)(?:\.(.+)|)$/,K=/(?:^|\s)hover(\.\S+|)\b/,Q=/^key/,G=/^(?:mouse|contextmenu)|click/,Y=/^(?:focusinfocus|focusoutblur)$/,Z=function(e){return v.event.special.hover?e:e.replace(K,"mouseenter$1 mouseleave$1")};v.event={add:function(e,n,r,i,s){var o,u,a,f,l,c,h,p,d,m,g;if(e.nodeType===3||e.nodeType===8||!n||!r||!(o=v._data(e)))return;r.handler&&(d=r,r=d.handler,s=d.selector),r.guid||(r.guid=v.guid++),a=o.events,a||(o.events=a={}),u=o.handle,u||(o.handle=u=function(e){return typeof v=="undefined"||!!e&&v.event.triggered===e.type?t:v.event.dispatch.apply(u.elem,arguments)},u.elem=e),n=v.trim(Z(n)).split(" ");for(f=0;f=0&&(y=y.slice(0,-1),a=!0),y.indexOf(".")>=0&&(b=y.split("."),y=b.shift(),b.sort());if((!s||v.event.customEvent[y])&&!v.event.global[y])return;n=typeof n=="object"?n[v.expando]?n:new v.Event(y,n):new v.Event(y),n.type=y,n.isTrigger=!0,n.exclusive=a,n.namespace=b.join("."),n.namespace_re=n.namespace?new RegExp("(^|\\.)"+b.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,h=y.indexOf(":")<0?"on"+y:"";if(!s){u=v.cache;for(f in u)u[f].events&&u[f].events[y]&&v.event.trigger(n,r,u[f].handle.elem,!0);return}n.result=t,n.target||(n.target=s),r=r!=null?v.makeArray(r):[],r.unshift(n),p=v.event.special[y]||{};if(p.trigger&&p.trigger.apply(s,r)===!1)return;m=[[s,p.bindType||y]];if(!o&&!p.noBubble&&!v.isWindow(s)){g=p.delegateType||y,l=Y.test(g+y)?s:s.parentNode;for(c=s;l;l=l.parentNode)m.push([l,g]),c=l;c===(s.ownerDocument||i)&&m.push([c.defaultView||c.parentWindow||e,g])}for(f=0;f=0:v.find(h,this,null,[s]).length),u[h]&&f.push(c);f.length&&w.push({elem:s,matches:f})}d.length>m&&w.push({elem:this,matches:d.slice(m)});for(r=0;r0?this.on(t,null,e,n):this.trigger(t)},Q.test(t)&&(v.event.fixHooks[t]=v.event.keyHooks),G.test(t)&&(v.event.fixHooks[t]=v.event.mouseHooks)}),function(e,t){function nt(e,t,n,r){n=n||[],t=t||g;var i,s,a,f,l=t.nodeType;if(!e||typeof e!="string")return n;if(l!==1&&l!==9)return[];a=o(t);if(!a&&!r)if(i=R.exec(e))if(f=i[1]){if(l===9){s=t.getElementById(f);if(!s||!s.parentNode)return n;if(s.id===f)return n.push(s),n}else if(t.ownerDocument&&(s=t.ownerDocument.getElementById(f))&&u(t,s)&&s.id===f)return n.push(s),n}else{if(i[2])return S.apply(n,x.call(t.getElementsByTagName(e),0)),n;if((f=i[3])&&Z&&t.getElementsByClassName)return S.apply(n,x.call(t.getElementsByClassName(f),0)),n}return vt(e.replace(j,"$1"),t,n,r,a)}function rt(e){return function(t){var n=t.nodeName.toLowerCase();return n==="input"&&t.type===e}}function it(e){return function(t){var n=t.nodeName.toLowerCase();return(n==="input"||n==="button")&&t.type===e}}function st(e){return N(function(t){return t=+t,N(function(n,r){var i,s=e([],n.length,t),o=s.length;while(o--)n[i=s[o]]&&(n[i]=!(r[i]=n[i]))})})}function ot(e,t,n){if(e===t)return n;var r=e.nextSibling;while(r){if(r===t)return-1;r=r.nextSibling}return 1}function ut(e,t){var n,r,s,o,u,a,f,l=L[d][e+" "];if(l)return t?0:l.slice(0);u=e,a=[],f=i.preFilter;while(u){if(!n||(r=F.exec(u)))r&&(u=u.slice(r[0].length)||u),a.push(s=[]);n=!1;if(r=I.exec(u))s.push(n=new m(r.shift())),u=u.slice(n.length),n.type=r[0].replace(j," ");for(o in i.filter)(r=J[o].exec(u))&&(!f[o]||(r=f[o](r)))&&(s.push(n=new m(r.shift())),u=u.slice(n.length),n.type=o,n.matches=r);if(!n)break}return t?u.length:u?nt.error(e):L(e,a).slice(0)}function at(e,t,r){var i=t.dir,s=r&&t.dir==="parentNode",o=w++;return t.first?function(t,n,r){while(t=t[i])if(s||t.nodeType===1)return e(t,n,r)}:function(t,r,u){if(!u){var a,f=b+" "+o+" ",l=f+n;while(t=t[i])if(s||t.nodeType===1){if((a=t[d])===l)return t.sizset;if(typeof a=="string"&&a.indexOf(f)===0){if(t.sizset)return t}else{t[d]=l;if(e(t,r,u))return t.sizset=!0,t;t.sizset=!1}}}else while(t=t[i])if(s||t.nodeType===1)if(e(t,r,u))return t}}function ft(e){return e.length>1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function lt(e,t,n,r,i){var s,o=[],u=0,a=e.length,f=t!=null;for(;u-1&&(s[f]=!(o[f]=c))}}else g=lt(g===o?g.splice(d,g.length):g),i?i(null,o,g,a):S.apply(o,g)})}function ht(e){var t,n,r,s=e.length,o=i.relative[e[0].type],u=o||i.relative[" "],a=o?1:0,f=at(function(e){return e===t},u,!0),l=at(function(e){return T.call(t,e)>-1},u,!0),h=[function(e,n,r){return!o&&(r||n!==c)||((t=n).nodeType?f(e,n,r):l(e,n,r))}];for(;a1&&ft(h),a>1&&e.slice(0,a-1).join("").replace(j,"$1"),n,a0,s=e.length>0,o=function(u,a,f,l,h){var p,d,v,m=[],y=0,w="0",x=u&&[],T=h!=null,N=c,C=u||s&&i.find.TAG("*",h&&a.parentNode||a),k=b+=N==null?1:Math.E;T&&(c=a!==g&&a,n=o.el);for(;(p=C[w])!=null;w++){if(s&&p){for(d=0;v=e[d];d++)if(v(p,a,f)){l.push(p);break}T&&(b=k,n=++o.el)}r&&((p=!v&&p)&&y--,u&&x.push(p))}y+=w;if(r&&w!==y){for(d=0;v=t[d];d++)v(x,m,a,f);if(u){if(y>0)while(w--)!x[w]&&!m[w]&&(m[w]=E.call(l));m=lt(m)}S.apply(l,m),T&&!u&&m.length>0&&y+t.length>1&&nt.uniqueSort(l)}return T&&(b=k,c=N),x};return o.el=0,r?N(o):o}function dt(e,t,n){var r=0,i=t.length;for(;r2&&(f=u[0]).type==="ID"&&t.nodeType===9&&!s&&i.relative[u[1].type]){t=i.find.ID(f.matches[0].replace($,""),t,s)[0];if(!t)return n;e=e.slice(u.shift().length)}for(o=J.POS.test(e)?-1:u.length-1;o>=0;o--){f=u[o];if(i.relative[l=f.type])break;if(c=i.find[l])if(r=c(f.matches[0].replace($,""),z.test(u[0].type)&&t.parentNode||t,s)){u.splice(o,1),e=r.length&&u.join("");if(!e)return S.apply(n,x.call(r,0)),n;break}}}return a(e,h)(r,t,s,n,z.test(e)),n}function mt(){}var n,r,i,s,o,u,a,f,l,c,h=!0,p="undefined",d=("sizcache"+Math.random()).replace(".",""),m=String,g=e.document,y=g.documentElement,b=0,w=0,E=[].pop,S=[].push,x=[].slice,T=[].indexOf||function(e){var t=0,n=this.length;for(;ti.cacheLength&&delete e[t.shift()],e[n+" "]=r},e)},k=C(),L=C(),A=C(),O="[\\x20\\t\\r\\n\\f]",M="(?:\\\\.|[-\\w]|[^\\x00-\\xa0])+",_=M.replace("w","w#"),D="([*^$|!~]?=)",P="\\["+O+"*("+M+")"+O+"*(?:"+D+O+"*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|("+_+")|)|)"+O+"*\\]",H=":("+M+")(?:\\((?:(['\"])((?:\\\\.|[^\\\\])*?)\\2|([^()[\\]]*|(?:(?:"+P+")|[^:]|\\\\.)*|.*))\\)|)",B=":(even|odd|eq|gt|lt|nth|first|last)(?:\\("+O+"*((?:-\\d)?\\d*)"+O+"*\\)|)(?=[^-]|$)",j=new RegExp("^"+O+"+|((?:^|[^\\\\])(?:\\\\.)*)"+O+"+$","g"),F=new RegExp("^"+O+"*,"+O+"*"),I=new RegExp("^"+O+"*([\\x20\\t\\r\\n\\f>+~])"+O+"*"),q=new RegExp(H),R=/^(?:#([\w\-]+)|(\w+)|\.([\w\-]+))$/,U=/^:not/,z=/[\x20\t\r\n\f]*[+~]/,W=/:not\($/,X=/h\d/i,V=/input|select|textarea|button/i,$=/\\(?!\\)/g,J={ID:new RegExp("^#("+M+")"),CLASS:new RegExp("^\\.("+M+")"),NAME:new RegExp("^\\[name=['\"]?("+M+")['\"]?\\]"),TAG:new RegExp("^("+M.replace("w","w*")+")"),ATTR:new RegExp("^"+P),PSEUDO:new RegExp("^"+H),POS:new RegExp(B,"i"),CHILD:new RegExp("^:(only|nth|first|last)-child(?:\\("+O+"*(even|odd|(([+-]|)(\\d*)n|)"+O+"*(?:([+-]|)"+O+"*(\\d+)|))"+O+"*\\)|)","i"),needsContext:new RegExp("^"+O+"*[>+~]|"+B,"i")},K=function(e){var t=g.createElement("div");try{return e(t)}catch(n){return!1}finally{t=null}},Q=K(function(e){return e.appendChild(g.createComment("")),!e.getElementsByTagName("*").length}),G=K(function(e){return e.innerHTML="",e.firstChild&&typeof e.firstChild.getAttribute!==p&&e.firstChild.getAttribute("href")==="#"}),Y=K(function(e){e.innerHTML="";var t=typeof e.lastChild.getAttribute("multiple");return t!=="boolean"&&t!=="string"}),Z=K(function(e){return e.innerHTML="",!e.getElementsByClassName||!e.getElementsByClassName("e").length?!1:(e.lastChild.className="e",e.getElementsByClassName("e").length===2)}),et=K(function(e){e.id=d+0,e.innerHTML="
",y.insertBefore(e,y.firstChild);var t=g.getElementsByName&&g.getElementsByName(d).length===2+g.getElementsByName(d+0).length;return r=!g.getElementById(d),y.removeChild(e),t});try{x.call(y.childNodes,0)[0].nodeType}catch(tt){x=function(e){var t,n=[];for(;t=this[e];e++)n.push(t);return n}}nt.matches=function(e,t){return nt(e,null,null,t)},nt.matchesSelector=function(e,t){return nt(t,null,null,[e]).length>0},s=nt.getText=function(e){var t,n="",r=0,i=e.nodeType;if(i){if(i===1||i===9||i===11){if(typeof e.textContent=="string")return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=s(e)}else if(i===3||i===4)return e.nodeValue}else for(;t=e[r];r++)n+=s(t);return n},o=nt.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return t?t.nodeName!=="HTML":!1},u=nt.contains=y.contains?function(e,t){var n=e.nodeType===9?e.documentElement:e,r=t&&t.parentNode;return e===r||!!(r&&r.nodeType===1&&n.contains&&n.contains(r))}:y.compareDocumentPosition?function(e,t){return t&&!!(e.compareDocumentPosition(t)&16)}:function(e,t){while(t=t.parentNode)if(t===e)return!0;return!1},nt.attr=function(e,t){var n,r=o(e);return r||(t=t.toLowerCase()),(n=i.attrHandle[t])?n(e):r||Y?e.getAttribute(t):(n=e.getAttributeNode(t),n?typeof e[t]=="boolean"?e[t]?t:null:n.specified?n.value:null:null)},i=nt.selectors={cacheLength:50,createPseudo:N,match:J,attrHandle:G?{}:{href:function(e){return e.getAttribute("href",2)},type:function(e){return e.getAttribute("type")}},find:{ID:r?function(e,t,n){if(typeof t.getElementById!==p&&!n){var r=t.getElementById(e);return r&&r.parentNode?[r]:[]}}:function(e,n,r){if(typeof n.getElementById!==p&&!r){var i=n.getElementById(e);return i?i.id===e||typeof i.getAttributeNode!==p&&i.getAttributeNode("id").value===e?[i]:t:[]}},TAG:Q?function(e,t){if(typeof t.getElementsByTagName!==p)return t.getElementsByTagName(e)}:function(e,t){var n=t.getElementsByTagName(e);if(e==="*"){var r,i=[],s=0;for(;r=n[s];s++)r.nodeType===1&&i.push(r);return i}return n},NAME:et&&function(e,t){if(typeof t.getElementsByName!==p)return t.getElementsByName(name)},CLASS:Z&&function(e,t,n){if(typeof t.getElementsByClassName!==p&&!n)return t.getElementsByClassName(e)}},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace($,""),e[3]=(e[4]||e[5]||"").replace($,""),e[2]==="~="&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),e[1]==="nth"?(e[2]||nt.error(e[0]),e[3]=+(e[3]?e[4]+(e[5]||1):2*(e[2]==="even"||e[2]==="odd")),e[4]=+(e[6]+e[7]||e[2]==="odd")):e[2]&&nt.error(e[0]),e},PSEUDO:function(e){var t,n;if(J.CHILD.test(e[0]))return null;if(e[3])e[2]=e[3];else if(t=e[4])q.test(t)&&(n=ut(t,!0))&&(n=t.indexOf(")",t.length-n)-t.length)&&(t=t.slice(0,n),e[0]=e[0].slice(0,n)),e[2]=t;return e.slice(0,3)}},filter:{ID:r?function(e){return e=e.replace($,""),function(t){return t.getAttribute("id")===e}}:function(e){return e=e.replace($,""),function(t){var n=typeof t.getAttributeNode!==p&&t.getAttributeNode("id");return n&&n.value===e}},TAG:function(e){return e==="*"?function(){return!0}:(e=e.replace($,"").toLowerCase(),function(t){return t.nodeName&&t.nodeName.toLowerCase()===e})},CLASS:function(e){var t=k[d][e+" "];return t||(t=new RegExp("(^|"+O+")"+e+"("+O+"|$)"))&&k(e,function(e){return t.test(e.className||typeof e.getAttribute!==p&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r,i){var s=nt.attr(r,e);return s==null?t==="!=":t?(s+="",t==="="?s===n:t==="!="?s!==n:t==="^="?n&&s.indexOf(n)===0:t==="*="?n&&s.indexOf(n)>-1:t==="$="?n&&s.substr(s.length-n.length)===n:t==="~="?(" "+s+" ").indexOf(n)>-1:t==="|="?s===n||s.substr(0,n.length+1)===n+"-":!1):!0}},CHILD:function(e,t,n,r){return e==="nth"?function(e){var t,i,s=e.parentNode;if(n===1&&r===0)return!0;if(s){i=0;for(t=s.firstChild;t;t=t.nextSibling)if(t.nodeType===1){i++;if(e===t)break}}return i-=r,i===n||i%n===0&&i/n>=0}:function(t){var n=t;switch(e){case"only":case"first":while(n=n.previousSibling)if(n.nodeType===1)return!1;if(e==="first")return!0;n=t;case"last":while(n=n.nextSibling)if(n.nodeType===1)return!1;return!0}}},PSEUDO:function(e,t){var n,r=i.pseudos[e]||i.setFilters[e.toLowerCase()]||nt.error("unsupported pseudo: "+e);return r[d]?r(t):r.length>1?(n=[e,e,"",t],i.setFilters.hasOwnProperty(e.toLowerCase())?N(function(e,n){var i,s=r(e,t),o=s.length;while(o--)i=T.call(e,s[o]),e[i]=!(n[i]=s[o])}):function(e){return r(e,0,n)}):r}},pseudos:{not:N(function(e){var t=[],n=[],r=a(e.replace(j,"$1"));return r[d]?N(function(e,t,n,i){var s,o=r(e,null,i,[]),u=e.length;while(u--)if(s=o[u])e[u]=!(t[u]=s)}):function(e,i,s){return t[0]=e,r(t,null,s,n),!n.pop()}}),has:N(function(e){return function(t){return nt(e,t).length>0}}),contains:N(function(e){return function(t){return(t.textContent||t.innerText||s(t)).indexOf(e)>-1}}),enabled:function(e){return e.disabled===!1},disabled:function(e){return e.disabled===!0},checked:function(e){var t=e.nodeName.toLowerCase();return t==="input"&&!!e.checked||t==="option"&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,e.selected===!0},parent:function(e){return!i.pseudos.empty(e)},empty:function(e){var t;e=e.firstChild;while(e){if(e.nodeName>"@"||(t=e.nodeType)===3||t===4)return!1;e=e.nextSibling}return!0},header:function(e){return X.test(e.nodeName)},text:function(e){var t,n;return e.nodeName.toLowerCase()==="input"&&(t=e.type)==="text"&&((n=e.getAttribute("type"))==null||n.toLowerCase()===t)},radio:rt("radio"),checkbox:rt("checkbox"),file:rt("file"),password:rt("password"),image:rt("image"),submit:it("submit"),reset:it("reset"),button:function(e){var t=e.nodeName.toLowerCase();return t==="input"&&e.type==="button"||t==="button"},input:function(e){return V.test(e.nodeName)},focus:function(e){var t=e.ownerDocument;return e===t.activeElement&&(!t.hasFocus||t.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},active:function(e){return e===e.ownerDocument.activeElement},first:st(function(){return[0]}),last:st(function(e,t){return[t-1]}),eq:st(function(e,t,n){return[n<0?n+t:n]}),even:st(function(e,t){for(var n=0;n=0;)e.push(r);return e}),gt:st(function(e,t,n){for(var r=n<0?n+t:n;++r",e.querySelectorAll("[selected]").length||i.push("\\["+O+"*(?:checked|disabled|ismap|multiple|readonly|selected|value)"),e.querySelectorAll(":checked").length||i.push(":checked")}),K(function(e){e.innerHTML="

",e.querySelectorAll("[test^='']").length&&i.push("[*^$]="+O+"*(?:\"\"|'')"),e.innerHTML="",e.querySelectorAll(":enabled").length||i.push(":enabled",":disabled")}),i=new RegExp(i.join("|")),vt=function(e,r,s,o,u){if(!o&&!u&&!i.test(e)){var a,f,l=!0,c=d,h=r,p=r.nodeType===9&&e;if(r.nodeType===1&&r.nodeName.toLowerCase()!=="object"){a=ut(e),(l=r.getAttribute("id"))?c=l.replace(n,"\\$&"):r.setAttribute("id",c),c="[id='"+c+"'] ",f=a.length;while(f--)a[f]=c+a[f].join("");h=z.test(e)&&r.parentNode||r,p=a.join(",")}if(p)try{return S.apply(s,x.call(h.querySelectorAll(p),0)),s}catch(v){}finally{l||r.removeAttribute("id")}}return t(e,r,s,o,u)},u&&(K(function(t){e=u.call(t,"div");try{u.call(t,"[test!='']:sizzle"),s.push("!=",H)}catch(n){}}),s=new RegExp(s.join("|")),nt.matchesSelector=function(t,n){n=n.replace(r,"='$1']");if(!o(t)&&!s.test(n)&&!i.test(n))try{var a=u.call(t,n);if(a||e||t.document&&t.document.nodeType!==11)return a}catch(f){}return nt(n,null,null,[t]).length>0})}(),i.pseudos.nth=i.pseudos.eq,i.filters=mt.prototype=i.pseudos,i.setFilters=new mt,nt.attr=v.attr,v.find=nt,v.expr=nt.selectors,v.expr[":"]=v.expr.pseudos,v.unique=nt.uniqueSort,v.text=nt.getText,v.isXMLDoc=nt.isXML,v.contains=nt.contains}(e);var nt=/Until$/,rt=/^(?:parents|prev(?:Until|All))/,it=/^.[^:#\[\.,]*$/,st=v.expr.match.needsContext,ot={children:!0,contents:!0,next:!0,prev:!0};v.fn.extend({find:function(e){var t,n,r,i,s,o,u=this;if(typeof e!="string")return v(e).filter(function(){for(t=0,n=u.length;t0)for(i=r;i=0:v.filter(e,this).length>0:this.filter(e).length>0)},closest:function(e,t){var n,r=0,i=this.length,s=[],o=st.test(e)||typeof e!="string"?v(e,t||this.context):0;for(;r-1:v.find.matchesSelector(n,e)){s.push(n);break}n=n.parentNode}}return s=s.length>1?v.unique(s):s,this.pushStack(s,"closest",e)},index:function(e){return e?typeof e=="string"?v.inArray(this[0],v(e)):v.inArray(e.jquery?e[0]:e,this):this[0]&&this[0].parentNode?this.prevAll().length:-1},add:function(e,t){var n=typeof e=="string"?v(e,t):v.makeArray(e&&e.nodeType?[e]:e),r=v.merge(this.get(),n);return this.pushStack(ut(n[0])||ut(r[0])?r:v.unique(r))},addBack:function(e){return this.add(e==null?this.prevObject:this.prevObject.filter(e))}}),v.fn.andSelf=v.fn.addBack,v.each({parent:function(e){var t=e.parentNode;return t&&t.nodeType!==11?t:null},parents:function(e){return v.dir(e,"parentNode")},parentsUntil:function(e,t,n){return v.dir(e,"parentNode",n)},next:function(e){return at(e,"nextSibling")},prev:function(e){return at(e,"previousSibling")},nextAll:function(e){return v.dir(e,"nextSibling")},prevAll:function(e){return v.dir(e,"previousSibling")},nextUntil:function(e,t,n){return v.dir(e,"nextSibling",n)},prevUntil:function(e,t,n){return v.dir(e,"previousSibling",n)},siblings:function(e){return v.sibling((e.parentNode||{}).firstChild,e)},children:function(e){return v.sibling(e.firstChild)},contents:function(e){return v.nodeName(e,"iframe")?e.contentDocument||e.contentWindow.document:v.merge([],e.childNodes)}},function(e,t){v.fn[e]=function(n,r){var i=v.map(this,t,n);return nt.test(e)||(r=n),r&&typeof r=="string"&&(i=v.filter(r,i)),i=this.length>1&&!ot[e]?v.unique(i):i,this.length>1&&rt.test(e)&&(i=i.reverse()),this.pushStack(i,e,l.call(arguments).join(","))}}),v.extend({filter:function(e,t,n){return n&&(e=":not("+e+")"),t.length===1?v.find.matchesSelector(t[0],e)?[t[0]]:[]:v.find.matches(e,t)},dir:function(e,n,r){var i=[],s=e[n];while(s&&s.nodeType!==9&&(r===t||s.nodeType!==1||!v(s).is(r)))s.nodeType===1&&i.push(s),s=s[n];return i},sibling:function(e,t){var n=[];for(;e;e=e.nextSibling)e.nodeType===1&&e!==t&&n.push(e);return n}});var ct="abbr|article|aside|audio|bdi|canvas|data|datalist|details|figcaption|figure|footer|header|hgroup|mark|meter|nav|output|progress|section|summary|time|video",ht=/ jQuery\d+="(?:null|\d+)"/g,pt=/^\s+/,dt=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,vt=/<([\w:]+)/,mt=/]","i"),Et=/^(?:checkbox|radio)$/,St=/checked\s*(?:[^=]|=\s*.checked.)/i,xt=/\/(java|ecma)script/i,Tt=/^\s*\s*$/g,Nt={option:[1,""],legend:[1,"
","
"],thead:[1,"","
"],tr:[2,"","
"],td:[3,"","
"],col:[2,"","
"],area:[1,"",""],_default:[0,"",""]},Ct=lt(i),kt=Ct.appendChild(i.createElement("div"));Nt.optgroup=Nt.option,Nt.tbody=Nt.tfoot=Nt.colgroup=Nt.caption=Nt.thead,Nt.th=Nt.td,v.support.htmlSerialize||(Nt._default=[1,"X
","
"]),v.fn.extend({text:function(e){return v.access(this,function(e){return e===t?v.text(this):this.empty().append((this[0]&&this[0].ownerDocument||i).createTextNode(e))},null,e,arguments.length)},wrapAll:function(e){if(v.isFunction(e))return this.each(function(t){v(this).wrapAll(e.call(this,t))});if(this[0]){var t=v(e,this[0].ownerDocument).eq(0).clone(!0);this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstChild&&e.firstChild.nodeType===1)e=e.firstChild;return e}).append(this)}return this},wrapInner:function(e){return v.isFunction(e)?this.each(function(t){v(this).wrapInner(e.call(this,t))}):this.each(function(){var t=v(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=v.isFunction(e);return this.each(function(n){v(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(){return this.parent().each(function(){v.nodeName(this,"body")||v(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,!0,function(e){(this.nodeType===1||this.nodeType===11)&&this.appendChild(e)})},prepend:function(){return this.domManip(arguments,!0,function(e){(this.nodeType===1||this.nodeType===11)&&this.insertBefore(e,this.firstChild)})},before:function(){if(!ut(this[0]))return this.domManip(arguments,!1,function(e){this.parentNode.insertBefore(e,this)});if(arguments.length){var e=v.clean(arguments);return this.pushStack(v.merge(e,this),"before",this.selector)}},after:function(){if(!ut(this[0]))return this.domManip(arguments,!1,function(e){this.parentNode.insertBefore(e,this.nextSibling)});if(arguments.length){var e=v.clean(arguments);return this.pushStack(v.merge(this,e),"after",this.selector)}},remove:function(e,t){var n,r=0;for(;(n=this[r])!=null;r++)if(!e||v.filter(e,[n]).length)!t&&n.nodeType===1&&(v.cleanData(n.getElementsByTagName("*")),v.cleanData([n])),n.parentNode&&n.parentNode.removeChild(n);return this},empty:function(){var e,t=0;for(;(e=this[t])!=null;t++){e.nodeType===1&&v.cleanData(e.getElementsByTagName("*"));while(e.firstChild)e.removeChild(e.firstChild)}return this},clone:function(e,t){return e=e==null?!1:e,t=t==null?e:t,this.map(function(){return v.clone(this,e,t)})},html:function(e){return v.access(this,function(e){var n=this[0]||{},r=0,i=this.length;if(e===t)return n.nodeType===1?n.innerHTML.replace(ht,""):t;if(typeof e=="string"&&!yt.test(e)&&(v.support.htmlSerialize||!wt.test(e))&&(v.support.leadingWhitespace||!pt.test(e))&&!Nt[(vt.exec(e)||["",""])[1].toLowerCase()]){e=e.replace(dt,"<$1>");try{for(;r1&&typeof f=="string"&&St.test(f))return this.each(function(){v(this).domManip(e,n,r)});if(v.isFunction(f))return this.each(function(i){var s=v(this);e[0]=f.call(this,i,n?s.html():t),s.domManip(e,n,r)});if(this[0]){i=v.buildFragment(e,this,l),o=i.fragment,s=o.firstChild,o.childNodes.length===1&&(o=s);if(s){n=n&&v.nodeName(s,"tr");for(u=i.cacheable||c-1;a0?this.clone(!0):this).get(),v(o[i])[t](r),s=s.concat(r);return this.pushStack(s,e,o.selector)}}),v.extend({clone:function(e,t,n){var r,i,s,o;v.support.html5Clone||v.isXMLDoc(e)||!wt.test("<"+e.nodeName+">")?o=e.cloneNode(!0):(kt.innerHTML=e.outerHTML,kt.removeChild(o=kt.firstChild));if((!v.support.noCloneEvent||!v.support.noCloneChecked)&&(e.nodeType===1||e.nodeType===11)&&!v.isXMLDoc(e)){Ot(e,o),r=Mt(e),i=Mt(o);for(s=0;r[s];++s)i[s]&&Ot(r[s],i[s])}if(t){At(e,o);if(n){r=Mt(e),i=Mt(o);for(s=0;r[s];++s)At(r[s],i[s])}}return r=i=null,o},clean:function(e,t,n,r){var s,o,u,a,f,l,c,h,p,d,m,g,y=t===i&&Ct,b=[];if(!t||typeof t.createDocumentFragment=="undefined")t=i;for(s=0;(u=e[s])!=null;s++){typeof u=="number"&&(u+="");if(!u)continue;if(typeof u=="string")if(!gt.test(u))u=t.createTextNode(u);else{y=y||lt(t),c=t.createElement("div"),y.appendChild(c),u=u.replace(dt,"<$1>"),a=(vt.exec(u)||["",""])[1].toLowerCase(),f=Nt[a]||Nt._default,l=f[0],c.innerHTML=f[1]+u+f[2];while(l--)c=c.lastChild;if(!v.support.tbody){h=mt.test(u),p=a==="table"&&!h?c.firstChild&&c.firstChild.childNodes:f[1]===""&&!h?c.childNodes:[];for(o=p.length-1;o>=0;--o)v.nodeName(p[o],"tbody")&&!p[o].childNodes.length&&p[o].parentNode.removeChild(p[o])}!v.support.leadingWhitespace&&pt.test(u)&&c.insertBefore(t.createTextNode(pt.exec(u)[0]),c.firstChild),u=c.childNodes,c.parentNode.removeChild(c)}u.nodeType?b.push(u):v.merge(b,u)}c&&(u=c=y=null);if(!v.support.appendChecked)for(s=0;(u=b[s])!=null;s++)v.nodeName(u,"input")?_t(u):typeof u.getElementsByTagName!="undefined"&&v.grep(u.getElementsByTagName("input"),_t);if(n){m=function(e){if(!e.type||xt.test(e.type))return r?r.push(e.parentNode?e.parentNode.removeChild(e):e):n.appendChild(e)};for(s=0;(u=b[s])!=null;s++)if(!v.nodeName(u,"script")||!m(u))n.appendChild(u),typeof u.getElementsByTagName!="undefined"&&(g=v.grep(v.merge([],u.getElementsByTagName("script")),m),b.splice.apply(b,[s+1,0].concat(g)),s+=g.length)}return b},cleanData:function(e,t){var n,r,i,s,o=0,u=v.expando,a=v.cache,f=v.support.deleteExpando,l=v.event.special;for(;(i=e[o])!=null;o++)if(t||v.acceptData(i)){r=i[u],n=r&&a[r];if(n){if(n.events)for(s in n.events)l[s]?v.event.remove(i,s):v.removeEvent(i,s,n.handle);a[r]&&(delete a[r],f?delete i[u]:i.removeAttribute?i.removeAttribute(u):i[u]=null,v.deletedIds.push(r))}}}}),function(){var e,t;v.uaMatch=function(e){e=e.toLowerCase();var t=/(chrome)[ \/]([\w.]+)/.exec(e)||/(webkit)[ \/]([\w.]+)/.exec(e)||/(opera)(?:.*version|)[ \/]([\w.]+)/.exec(e)||/(msie) ([\w.]+)/.exec(e)||e.indexOf("compatible")<0&&/(mozilla)(?:.*? rv:([\w.]+)|)/.exec(e)||[];return{browser:t[1]||"",version:t[2]||"0"}},e=v.uaMatch(o.userAgent),t={},e.browser&&(t[e.browser]=!0,t.version=e.version),t.chrome?t.webkit=!0:t.webkit&&(t.safari=!0),v.browser=t,v.sub=function(){function e(t,n){return new e.fn.init(t,n)}v.extend(!0,e,this),e.superclass=this,e.fn=e.prototype=this(),e.fn.constructor=e,e.sub=this.sub,e.fn.init=function(r,i){return i&&i instanceof v&&!(i instanceof e)&&(i=e(i)),v.fn.init.call(this,r,i,t)},e.fn.init.prototype=e.fn;var t=e(i);return e}}();var Dt,Pt,Ht,Bt=/alpha\([^)]*\)/i,jt=/opacity=([^)]*)/,Ft=/^(top|right|bottom|left)$/,It=/^(none|table(?!-c[ea]).+)/,qt=/^margin/,Rt=new RegExp("^("+m+")(.*)$","i"),Ut=new RegExp("^("+m+")(?!px)[a-z%]+$","i"),zt=new RegExp("^([-+])=("+m+")","i"),Wt={BODY:"block"},Xt={position:"absolute",visibility:"hidden",display:"block"},Vt={letterSpacing:0,fontWeight:400},$t=["Top","Right","Bottom","Left"],Jt=["Webkit","O","Moz","ms"],Kt=v.fn.toggle;v.fn.extend({css:function(e,n){return v.access(this,function(e,n,r){return r!==t?v.style(e,n,r):v.css(e,n)},e,n,arguments.length>1)},show:function(){return Yt(this,!0)},hide:function(){return Yt(this)},toggle:function(e,t){var n=typeof e=="boolean";return v.isFunction(e)&&v.isFunction(t)?Kt.apply(this,arguments):this.each(function(){(n?e:Gt(this))?v(this).show():v(this).hide()})}}),v.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=Dt(e,"opacity");return n===""?"1":n}}}},cssNumber:{fillOpacity:!0,fontWeight:!0,lineHeight:!0,opacity:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{"float":v.support.cssFloat?"cssFloat":"styleFloat"},style:function(e,n,r,i){if(!e||e.nodeType===3||e.nodeType===8||!e.style)return;var s,o,u,a=v.camelCase(n),f=e.style;n=v.cssProps[a]||(v.cssProps[a]=Qt(f,a)),u=v.cssHooks[n]||v.cssHooks[a];if(r===t)return u&&"get"in u&&(s=u.get(e,!1,i))!==t?s:f[n];o=typeof r,o==="string"&&(s=zt.exec(r))&&(r=(s[1]+1)*s[2]+parseFloat(v.css(e,n)),o="number");if(r==null||o==="number"&&isNaN(r))return;o==="number"&&!v.cssNumber[a]&&(r+="px");if(!u||!("set"in u)||(r=u.set(e,r,i))!==t)try{f[n]=r}catch(l){}},css:function(e,n,r,i){var s,o,u,a=v.camelCase(n);return n=v.cssProps[a]||(v.cssProps[a]=Qt(e.style,a)),u=v.cssHooks[n]||v.cssHooks[a],u&&"get"in u&&(s=u.get(e,!0,i)),s===t&&(s=Dt(e,n)),s==="normal"&&n in Vt&&(s=Vt[n]),r||i!==t?(o=parseFloat(s),r||v.isNumeric(o)?o||0:s):s},swap:function(e,t,n){var r,i,s={};for(i in t)s[i]=e.style[i],e.style[i]=t[i];r=n.call(e);for(i in t)e.style[i]=s[i];return r}}),e.getComputedStyle?Dt=function(t,n){var r,i,s,o,u=e.getComputedStyle(t,null),a=t.style;return u&&(r=u.getPropertyValue(n)||u[n],r===""&&!v.contains(t.ownerDocument,t)&&(r=v.style(t,n)),Ut.test(r)&&qt.test(n)&&(i=a.width,s=a.minWidth,o=a.maxWidth,a.minWidth=a.maxWidth=a.width=r,r=u.width,a.width=i,a.minWidth=s,a.maxWidth=o)),r}:i.documentElement.currentStyle&&(Dt=function(e,t){var n,r,i=e.currentStyle&&e.currentStyle[t],s=e.style;return i==null&&s&&s[t]&&(i=s[t]),Ut.test(i)&&!Ft.test(t)&&(n=s.left,r=e.runtimeStyle&&e.runtimeStyle.left,r&&(e.runtimeStyle.left=e.currentStyle.left),s.left=t==="fontSize"?"1em":i,i=s.pixelLeft+"px",s.left=n,r&&(e.runtimeStyle.left=r)),i===""?"auto":i}),v.each(["height","width"],function(e,t){v.cssHooks[t]={get:function(e,n,r){if(n)return e.offsetWidth===0&&It.test(Dt(e,"display"))?v.swap(e,Xt,function(){return tn(e,t,r)}):tn(e,t,r)},set:function(e,n,r){return Zt(e,n,r?en(e,t,r,v.support.boxSizing&&v.css(e,"boxSizing")==="border-box"):0)}}}),v.support.opacity||(v.cssHooks.opacity={get:function(e,t){return jt.test((t&&e.currentStyle?e.currentStyle.filter:e.style.filter)||"")?.01*parseFloat(RegExp.$1)+"":t?"1":""},set:function(e,t){var n=e.style,r=e.currentStyle,i=v.isNumeric(t)?"alpha(opacity="+t*100+")":"",s=r&&r.filter||n.filter||"";n.zoom=1;if(t>=1&&v.trim(s.replace(Bt,""))===""&&n.removeAttribute){n.removeAttribute("filter");if(r&&!r.filter)return}n.filter=Bt.test(s)?s.replace(Bt,i):s+" "+i}}),v(function(){v.support.reliableMarginRight||(v.cssHooks.marginRight={get:function(e,t){return v.swap(e,{display:"inline-block"},function(){if(t)return Dt(e,"marginRight")})}}),!v.support.pixelPosition&&v.fn.position&&v.each(["top","left"],function(e,t){v.cssHooks[t]={get:function(e,n){if(n){var r=Dt(e,t);return Ut.test(r)?v(e).position()[t]+"px":r}}}})}),v.expr&&v.expr.filters&&(v.expr.filters.hidden=function(e){return e.offsetWidth===0&&e.offsetHeight===0||!v.support.reliableHiddenOffsets&&(e.style&&e.style.display||Dt(e,"display"))==="none"},v.expr.filters.visible=function(e){return!v.expr.filters.hidden(e)}),v.each({margin:"",padding:"",border:"Width"},function(e,t){v.cssHooks[e+t]={expand:function(n){var r,i=typeof n=="string"?n.split(" "):[n],s={};for(r=0;r<4;r++)s[e+$t[r]+t]=i[r]||i[r-2]||i[0];return s}},qt.test(e)||(v.cssHooks[e+t].set=Zt)});var rn=/%20/g,sn=/\[\]$/,on=/\r?\n/g,un=/^(?:color|date|datetime|datetime-local|email|hidden|month|number|password|range|search|tel|text|time|url|week)$/i,an=/^(?:select|textarea)/i;v.fn.extend({serialize:function(){return v.param(this.serializeArray())},serializeArray:function(){return this.map(function(){return this.elements?v.makeArray(this.elements):this}).filter(function(){return this.name&&!this.disabled&&(this.checked||an.test(this.nodeName)||un.test(this.type))}).map(function(e,t){var n=v(this).val();return n==null?null:v.isArray(n)?v.map(n,function(e,n){return{name:t.name,value:e.replace(on,"\r\n")}}):{name:t.name,value:n.replace(on,"\r\n")}}).get()}}),v.param=function(e,n){var r,i=[],s=function(e,t){t=v.isFunction(t)?t():t==null?"":t,i[i.length]=encodeURIComponent(e)+"="+encodeURIComponent(t)};n===t&&(n=v.ajaxSettings&&v.ajaxSettings.traditional);if(v.isArray(e)||e.jquery&&!v.isPlainObject(e))v.each(e,function(){s(this.name,this.value)});else for(r in e)fn(r,e[r],n,s);return i.join("&").replace(rn,"+")};var ln,cn,hn=/#.*$/,pn=/^(.*?):[ \t]*([^\r\n]*)\r?$/mg,dn=/^(?:about|app|app\-storage|.+\-extension|file|res|widget):$/,vn=/^(?:GET|HEAD)$/,mn=/^\/\//,gn=/\?/,yn=/)<[^<]*)*<\/script>/gi,bn=/([?&])_=[^&]*/,wn=/^([\w\+\.\-]+:)(?:\/\/([^\/?#:]*)(?::(\d+)|)|)/,En=v.fn.load,Sn={},xn={},Tn=["*/"]+["*"];try{cn=s.href}catch(Nn){cn=i.createElement("a"),cn.href="",cn=cn.href}ln=wn.exec(cn.toLowerCase())||[],v.fn.load=function(e,n,r){if(typeof e!="string"&&En)return En.apply(this,arguments);if(!this.length)return this;var i,s,o,u=this,a=e.indexOf(" ");return a>=0&&(i=e.slice(a,e.length),e=e.slice(0,a)),v.isFunction(n)?(r=n,n=t):n&&typeof n=="object"&&(s="POST"),v.ajax({url:e,type:s,dataType:"html",data:n,complete:function(e,t){r&&u.each(r,o||[e.responseText,t,e])}}).done(function(e){o=arguments,u.html(i?v("
").append(e.replace(yn,"")).find(i):e)}),this},v.each("ajaxStart ajaxStop ajaxComplete ajaxError ajaxSuccess ajaxSend".split(" "),function(e,t){v.fn[t]=function(e){return this.on(t,e)}}),v.each(["get","post"],function(e,n){v[n]=function(e,r,i,s){return v.isFunction(r)&&(s=s||i,i=r,r=t),v.ajax({type:n,url:e,data:r,success:i,dataType:s})}}),v.extend({getScript:function(e,n){return v.get(e,t,n,"script")},getJSON:function(e,t,n){return v.get(e,t,n,"json")},ajaxSetup:function(e,t){return t?Ln(e,v.ajaxSettings):(t=e,e=v.ajaxSettings),Ln(e,t),e},ajaxSettings:{url:cn,isLocal:dn.test(ln[1]),global:!0,type:"GET",contentType:"application/x-www-form-urlencoded; charset=UTF-8",processData:!0,async:!0,accepts:{xml:"application/xml, text/xml",html:"text/html",text:"text/plain",json:"application/json, text/javascript","*":Tn},contents:{xml:/xml/,html:/html/,json:/json/},responseFields:{xml:"responseXML",text:"responseText"},converters:{"* text":e.String,"text html":!0,"text json":v.parseJSON,"text xml":v.parseXML},flatOptions:{context:!0,url:!0}},ajaxPrefilter:Cn(Sn),ajaxTransport:Cn(xn),ajax:function(e,n){function T(e,n,s,a){var l,y,b,w,S,T=n;if(E===2)return;E=2,u&&clearTimeout(u),o=t,i=a||"",x.readyState=e>0?4:0,s&&(w=An(c,x,s));if(e>=200&&e<300||e===304)c.ifModified&&(S=x.getResponseHeader("Last-Modified"),S&&(v.lastModified[r]=S),S=x.getResponseHeader("Etag"),S&&(v.etag[r]=S)),e===304?(T="notmodified",l=!0):(l=On(c,w),T=l.state,y=l.data,b=l.error,l=!b);else{b=T;if(!T||e)T="error",e<0&&(e=0)}x.status=e,x.statusText=(n||T)+"",l?d.resolveWith(h,[y,T,x]):d.rejectWith(h,[x,T,b]),x.statusCode(g),g=t,f&&p.trigger("ajax"+(l?"Success":"Error"),[x,c,l?y:b]),m.fireWith(h,[x,T]),f&&(p.trigger("ajaxComplete",[x,c]),--v.active||v.event.trigger("ajaxStop"))}typeof e=="object"&&(n=e,e=t),n=n||{};var r,i,s,o,u,a,f,l,c=v.ajaxSetup({},n),h=c.context||c,p=h!==c&&(h.nodeType||h instanceof v)?v(h):v.event,d=v.Deferred(),m=v.Callbacks("once memory"),g=c.statusCode||{},b={},w={},E=0,S="canceled",x={readyState:0,setRequestHeader:function(e,t){if(!E){var n=e.toLowerCase();e=w[n]=w[n]||e,b[e]=t}return this},getAllResponseHeaders:function(){return E===2?i:null},getResponseHeader:function(e){var n;if(E===2){if(!s){s={};while(n=pn.exec(i))s[n[1].toLowerCase()]=n[2]}n=s[e.toLowerCase()]}return n===t?null:n},overrideMimeType:function(e){return E||(c.mimeType=e),this},abort:function(e){return e=e||S,o&&o.abort(e),T(0,e),this}};d.promise(x),x.success=x.done,x.error=x.fail,x.complete=m.add,x.statusCode=function(e){if(e){var t;if(E<2)for(t in e)g[t]=[g[t],e[t]];else t=e[x.status],x.always(t)}return this},c.url=((e||c.url)+"").replace(hn,"").replace(mn,ln[1]+"//"),c.dataTypes=v.trim(c.dataType||"*").toLowerCase().split(y),c.crossDomain==null&&(a=wn.exec(c.url.toLowerCase()),c.crossDomain=!(!a||a[1]===ln[1]&&a[2]===ln[2]&&(a[3]||(a[1]==="http:"?80:443))==(ln[3]||(ln[1]==="http:"?80:443)))),c.data&&c.processData&&typeof c.data!="string"&&(c.data=v.param(c.data,c.traditional)),kn(Sn,c,n,x);if(E===2)return x;f=c.global,c.type=c.type.toUpperCase(),c.hasContent=!vn.test(c.type),f&&v.active++===0&&v.event.trigger("ajaxStart");if(!c.hasContent){c.data&&(c.url+=(gn.test(c.url)?"&":"?")+c.data,delete c.data),r=c.url;if(c.cache===!1){var N=v.now(),C=c.url.replace(bn,"$1_="+N);c.url=C+(C===c.url?(gn.test(c.url)?"&":"?")+"_="+N:"")}}(c.data&&c.hasContent&&c.contentType!==!1||n.contentType)&&x.setRequestHeader("Content-Type",c.contentType),c.ifModified&&(r=r||c.url,v.lastModified[r]&&x.setRequestHeader("If-Modified-Since",v.lastModified[r]),v.etag[r]&&x.setRequestHeader("If-None-Match",v.etag[r])),x.setRequestHeader("Accept",c.dataTypes[0]&&c.accepts[c.dataTypes[0]]?c.accepts[c.dataTypes[0]]+(c.dataTypes[0]!=="*"?", "+Tn+"; q=0.01":""):c.accepts["*"]);for(l in c.headers)x.setRequestHeader(l,c.headers[l]);if(!c.beforeSend||c.beforeSend.call(h,x,c)!==!1&&E!==2){S="abort";for(l in{success:1,error:1,complete:1})x[l](c[l]);o=kn(xn,c,n,x);if(!o)T(-1,"No Transport");else{x.readyState=1,f&&p.trigger("ajaxSend",[x,c]),c.async&&c.timeout>0&&(u=setTimeout(function(){x.abort("timeout")},c.timeout));try{E=1,o.send(b,T)}catch(k){if(!(E<2))throw k;T(-1,k)}}return x}return x.abort()},active:0,lastModified:{},etag:{}});var Mn=[],_n=/\?/,Dn=/(=)\?(?=&|$)|\?\?/,Pn=v.now();v.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Mn.pop()||v.expando+"_"+Pn++;return this[e]=!0,e}}),v.ajaxPrefilter("json jsonp",function(n,r,i){var s,o,u,a=n.data,f=n.url,l=n.jsonp!==!1,c=l&&Dn.test(f),h=l&&!c&&typeof a=="string"&&!(n.contentType||"").indexOf("application/x-www-form-urlencoded")&&Dn.test(a);if(n.dataTypes[0]==="jsonp"||c||h)return s=n.jsonpCallback=v.isFunction(n.jsonpCallback)?n.jsonpCallback():n.jsonpCallback,o=e[s],c?n.url=f.replace(Dn,"$1"+s):h?n.data=a.replace(Dn,"$1"+s):l&&(n.url+=(_n.test(f)?"&":"?")+n.jsonp+"="+s),n.converters["script json"]=function(){return u||v.error(s+" was not called"),u[0]},n.dataTypes[0]="json",e[s]=function(){u=arguments},i.always(function(){e[s]=o,n[s]&&(n.jsonpCallback=r.jsonpCallback,Mn.push(s)),u&&v.isFunction(o)&&o(u[0]),u=o=t}),"script"}),v.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/javascript|ecmascript/},converters:{"text script":function(e){return v.globalEval(e),e}}}),v.ajaxPrefilter("script",function(e){e.cache===t&&(e.cache=!1),e.crossDomain&&(e.type="GET",e.global=!1)}),v.ajaxTransport("script",function(e){if(e.crossDomain){var n,r=i.head||i.getElementsByTagName("head")[0]||i.documentElement;return{send:function(s,o){n=i.createElement("script"),n.async="async",e.scriptCharset&&(n.charset=e.scriptCharset),n.src=e.url,n.onload=n.onreadystatechange=function(e,i){if(i||!n.readyState||/loaded|complete/.test(n.readyState))n.onload=n.onreadystatechange=null,r&&n.parentNode&&r.removeChild(n),n=t,i||o(200,"success")},r.insertBefore(n,r.firstChild)},abort:function(){n&&n.onload(0,1)}}}});var Hn,Bn=e.ActiveXObject?function(){for(var e in Hn)Hn[e](0,1)}:!1,jn=0;v.ajaxSettings.xhr=e.ActiveXObject?function(){return!this.isLocal&&Fn()||In()}:Fn,function(e){v.extend(v.support,{ajax:!!e,cors:!!e&&"withCredentials"in e})}(v.ajaxSettings.xhr()),v.support.ajax&&v.ajaxTransport(function(n){if(!n.crossDomain||v.support.cors){var r;return{send:function(i,s){var o,u,a=n.xhr();n.username?a.open(n.type,n.url,n.async,n.username,n.password):a.open(n.type,n.url,n.async);if(n.xhrFields)for(u in n.xhrFields)a[u]=n.xhrFields[u];n.mimeType&&a.overrideMimeType&&a.overrideMimeType(n.mimeType),!n.crossDomain&&!i["X-Requested-With"]&&(i["X-Requested-With"]="XMLHttpRequest");try{for(u in i)a.setRequestHeader(u,i[u])}catch(f){}a.send(n.hasContent&&n.data||null),r=function(e,i){var u,f,l,c,h;try{if(r&&(i||a.readyState===4)){r=t,o&&(a.onreadystatechange=v.noop,Bn&&delete Hn[o]);if(i)a.readyState!==4&&a.abort();else{u=a.status,l=a.getAllResponseHeaders(),c={},h=a.responseXML,h&&h.documentElement&&(c.xml=h);try{c.text=a.responseText}catch(p){}try{f=a.statusText}catch(p){f=""}!u&&n.isLocal&&!n.crossDomain?u=c.text?200:404:u===1223&&(u=204)}}}catch(d){i||s(-1,d)}c&&s(u,f,c,l)},n.async?a.readyState===4?setTimeout(r,0):(o=++jn,Bn&&(Hn||(Hn={},v(e).unload(Bn)),Hn[o]=r),a.onreadystatechange=r):r()},abort:function(){r&&r(0,1)}}}});var qn,Rn,Un=/^(?:toggle|show|hide)$/,zn=new RegExp("^(?:([-+])=|)("+m+")([a-z%]*)$","i"),Wn=/queueHooks$/,Xn=[Gn],Vn={"*":[function(e,t){var n,r,i=this.createTween(e,t),s=zn.exec(t),o=i.cur(),u=+o||0,a=1,f=20;if(s){n=+s[2],r=s[3]||(v.cssNumber[e]?"":"px");if(r!=="px"&&u){u=v.css(i.elem,e,!0)||n||1;do a=a||".5",u/=a,v.style(i.elem,e,u+r);while(a!==(a=i.cur()/o)&&a!==1&&--f)}i.unit=r,i.start=u,i.end=s[1]?u+(s[1]+1)*n:n}return i}]};v.Animation=v.extend(Kn,{tweener:function(e,t){v.isFunction(e)?(t=e,e=["*"]):e=e.split(" ");var n,r=0,i=e.length;for(;r-1,f={},l={},c,h;a?(l=i.position(),c=l.top,h=l.left):(c=parseFloat(o)||0,h=parseFloat(u)||0),v.isFunction(t)&&(t=t.call(e,n,s)),t.top!=null&&(f.top=t.top-s.top+c),t.left!=null&&(f.left=t.left-s.left+h),"using"in t?t.using.call(e,f):i.css(f)}},v.fn.extend({position:function(){if(!this[0])return;var e=this[0],t=this.offsetParent(),n=this.offset(),r=er.test(t[0].nodeName)?{top:0,left:0}:t.offset();return n.top-=parseFloat(v.css(e,"marginTop"))||0,n.left-=parseFloat(v.css(e,"marginLeft"))||0,r.top+=parseFloat(v.css(t[0],"borderTopWidth"))||0,r.left+=parseFloat(v.css(t[0],"borderLeftWidth"))||0,{top:n.top-r.top,left:n.left-r.left}},offsetParent:function(){return this.map(function(){var e=this.offsetParent||i.body;while(e&&!er.test(e.nodeName)&&v.css(e,"position")==="static")e=e.offsetParent;return e||i.body})}}),v.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(e,n){var r=/Y/.test(n);v.fn[e]=function(i){return v.access(this,function(e,i,s){var o=tr(e);if(s===t)return o?n in o?o[n]:o.document.documentElement[i]:e[i];o?o.scrollTo(r?v(o).scrollLeft():s,r?s:v(o).scrollTop()):e[i]=s},e,i,arguments.length,null)}}),v.each({Height:"height",Width:"width"},function(e,n){v.each({padding:"inner"+e,content:n,"":"outer"+e},function(r,i){v.fn[i]=function(i,s){var o=arguments.length&&(r||typeof i!="boolean"),u=r||(i===!0||s===!0?"margin":"border");return v.access(this,function(n,r,i){var s;return v.isWindow(n)?n.document.documentElement["client"+e]:n.nodeType===9?(s=n.documentElement,Math.max(n.body["scroll"+e],s["scroll"+e],n.body["offset"+e],s["offset"+e],s["client"+e])):i===t?v.css(n,r,i,u):v.style(n,r,i,u)},n,o?i:t,o,null)}})}),e.jQuery=e.$=v,typeof define=="function"&&define.amd&&define.amd.jQuery&&define("jquery",[],function(){return v})})(window); \ No newline at end of file diff --git a/mopidy/frontends/http/data/mopidy.html b/mopidy/frontends/http/data/mopidy.html index 64501830..c756cd6c 100644 --- a/mopidy/frontends/http/data/mopidy.html +++ b/mopidy/frontends/http/data/mopidy.html @@ -38,15 +38,14 @@

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

- From 0b9ee92edbabf4410605fd97ef29e42f43d421d3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 28 Nov 2012 21:14:44 +0100 Subject: [PATCH 070/144] local: Change log level from error to warning --- mopidy/backends/local/playlists.py | 2 +- mopidy/backends/local/translator.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/local/playlists.py b/mopidy/backends/local/playlists.py index 666532c5..53f7aaae 100644 --- a/mopidy/backends/local/playlists.py +++ b/mopidy/backends/local/playlists.py @@ -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) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 21e389ea..59e2957a 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -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 = {} From 924dae46281edcec55d608662becce9456b78424 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:16:05 +0100 Subject: [PATCH 071/144] js: Ignore node_modules dirs --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 21adc7af..9229541f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ coverage.xml dist/ docs/_build/ mopidy.log* +node_modules/ nosetests.xml From 2352dc8a033e7254369b055b712f33e88f7e8ade Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:18:21 +0100 Subject: [PATCH 072/144] js: Add Buster.js test config --- js/buster.js | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 js/buster.js diff --git a/js/buster.js b/js/buster.js new file mode 100644 index 00000000..f789885a --- /dev/null +++ b/js/buster.js @@ -0,0 +1,9 @@ +var config = module.exports; + +config["tests"] = { + environment: "browser", + libs: ["lib/**/*.js"], + sources: ["src/**/*.js"], + testHelpers: ["test/**/*-helper.js"], + tests: ["test/**/*-test.js"] +}; From 01c972fd85fb378c2d6fdd4b2451c451c71e2ba9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:18:40 +0100 Subject: [PATCH 073/144] js: Add Grunt build config --- js/grunt.js | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 js/grunt.js diff --git a/js/grunt.js b/js/grunt.js new file mode 100644 index 00000000..97c3b25f --- /dev/null +++ b/js/grunt.js @@ -0,0 +1,65 @@ +/*global module:false*/ +module.exports = function (grunt) { + + grunt.initConfig({ + meta: { + banner: "/*! Mopidy.js - built " + + "<%= grunt.template.today('yyyy-mm-dd') %>\n" + + " * http://www.mopidy.com/\n" + + " * Copyright (c) <%= grunt.template.today('yyyy') %> " + + "Stein Magnus Jodal and contributors\n" + + " * Licensed under the Apache License, Version 2.0 */" + }, + dirs: { + dest: "../mopidy/frontends/http/data" + }, + lint: { + files: ["grunt.js", "src/**/*.js", "test/**/*-test.js"] + }, + buster: { + test: { + config: "buster.js" + } + }, + concat: { + dist: { + src: ["", "lib/**/*.js", "src/mopidy.js"], + dest: "<%= dirs.dest %>/mopidy.js" + } + }, + min: { + dist: { + src: ["", ""], + dest: "<%= dirs.dest %>/mopidy.min.js" + } + }, + watch: { + files: "", + tasks: "lint buster" + }, + jshint: { + options: { + curly: true, + eqeqeq: true, + immed: true, + indent: 4, + latedef: true, + newcap: true, + noarg: true, + sub: true, + quotmark: "double", + undef: true, + unused: true, + eqnull: true, + browser: true, + devel: true + }, + globals: {} + }, + uglify: {} + }); + + grunt.registerTask("default", "lint buster concat min"); + + grunt.loadNpmTasks("grunt-buster"); +}; From 2268a347a0b2fc123cb8d827f754deb315a06dec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:19:21 +0100 Subject: [PATCH 074/144] js: Add bane 0.3.0 for event handling --- js/lib/bane-0.3.0.js | 141 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 js/lib/bane-0.3.0.js diff --git a/js/lib/bane-0.3.0.js b/js/lib/bane-0.3.0.js new file mode 100644 index 00000000..53d0ed0d --- /dev/null +++ b/js/lib/bane-0.3.0.js @@ -0,0 +1,141 @@ +((typeof define === "function" && define.amd && function (m) { define(m); }) || + (typeof module === "object" && function (m) { module.exports = m(); }) || + function (m) { this.bane = m(); } +)(function () { + "use strict"; + var slice = Array.prototype.slice; + + function handleError(event, error, errbacks) { + var i, l = errbacks.length; + if (l > 0) { + for (i = 0; i < l; ++i) { errbacks[i](event, error); } + return; + } + setTimeout(function () { + error.message = event + " listener threw error: " + error.message; + throw error; + }, 0); + } + + function assertFunction(fn) { + if (typeof fn !== "function") { + throw new TypeError("Listener is not function"); + } + return fn; + } + + function supervisors(object) { + if (!object.supervisors) { object.supervisors = []; } + return object.supervisors; + } + + function listeners(object, event) { + if (!object.listeners) { object.listeners = {}; } + if (event && !object.listeners[event]) { object.listeners[event] = []; } + return event ? object.listeners[event] : object.listeners; + } + + /** + * @signature var emitter = bane.createEmitter([object]); + * + * Create a new event emitter. If an object is passed, it will be modified + * by adding the event emitter methods (see below). + */ + function createEventEmitter(object) { + object = object || {}; + + function notifyListener(event, listener, args) { + try { + listener.listener.apply(listener.thisp || object, args); + } catch (e) { + handleError(event, e, object.errbacks || []); + } + } + + object.on = function (event, listener, thisp) { + if (typeof event === "function") { + return supervisors(this).push({ + listener: event, + thisp: listener + }); + } + listeners(this, event).push({ + listener: assertFunction(listener), + thisp: thisp + }); + }; + + object.off = function (event, listener) { + var fns, i, l; + if (typeof event === "function") { + fns = supervisors(this); + listener = event; + } else { + fns = listeners(this, event); + } + if (!listener) { + fns.splice(0, fns.length); + return; + } + for (i = 0, l = fns.length; i < l; ++i) { + if (fns[i].listener === listener) { + fns.splice(i, 1); + return; + } + } + }; + + object.once = function (event, listener, thisp) { + var wrapper = function () { + object.off(event, wrapper); + listener.apply(this, arguments); + }; + + object.on(event, wrapper, thisp); + }; + + object.bind = function (object, events) { + var prop, i, l; + if (!events) { + for (prop in object) { + if (typeof object[prop] === "function") { + this.on(prop, object[prop], object); + } + } + } else { + for (i = 0, l = events.length; i < l; ++i) { + if (typeof object[events[i]] === "function") { + this.on(events[i], object[events[i]], object); + } else { + throw new Error("No such method " + events[i]); + } + } + } + return object; + }; + + object.emit = function (event) { + var toNotify = listeners(this, event).slice(); + var args = slice.call(arguments, 1), i, l; + + for (i = 0, l = toNotify.length; i < l; i++) { + notifyListener(event, toNotify[i], args); + } + + toNotify = supervisors(this); + args = slice.call(arguments); + for (i = 0, l = toNotify.length; i < l; ++i) { + notifyListener(event, toNotify[i], args); + } + }; + + object.errback = function (listener) { + if (!this.errbacks) { this.errbacks = []; } + this.errbacks.push(assertFunction(listener)); + }; + + return object; + } + + return { createEventEmitter: createEventEmitter }; +}); From ceb71b40a189d10ebf1e705ab11bdabd6dd7dcb3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:19:41 +0100 Subject: [PATCH 075/144] js: Add when 1.6.1 for Promise support --- js/lib/when-1.6.1.js | 731 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 731 insertions(+) create mode 100644 js/lib/when-1.6.1.js diff --git a/js/lib/when-1.6.1.js b/js/lib/when-1.6.1.js new file mode 100644 index 00000000..e9a3bfc3 --- /dev/null +++ b/js/lib/when-1.6.1.js @@ -0,0 +1,731 @@ +/** @license MIT License (c) copyright B Cavalier & J Hann */ + +/** + * A lightweight CommonJS Promises/A and when() implementation + * when is part of the cujo.js family of libraries (http://cujojs.com/) + * + * Licensed under the MIT License at: + * http://www.opensource.org/licenses/mit-license.php + * + * @version 1.6.1 + */ + +(function(define) { 'use strict'; +define(['module'], function () { + var reduceArray, slice, undef; + + // + // Public API + // + + when.defer = defer; // Create a deferred + when.resolve = resolve; // Create a resolved promise + when.reject = reject; // Create a rejected promise + + when.join = join; // Join 2 or more promises + + when.all = all; // Resolve a list of promises + when.some = some; // Resolve a sub-set of promises + when.any = any; // Resolve one promise in a list + + when.map = map; // Array.map() for promises + when.reduce = reduce; // Array.reduce() for promises + + when.chain = chain; // Make a promise trigger another resolver + + when.isPromise = isPromise; // Determine if a thing is a promise + + /** + * Register an observer for a promise or immediate value. + * @function + * @name when + * @namespace + * + * @param promiseOrValue {*} + * @param {Function} [callback] callback to be called when promiseOrValue is + * successfully fulfilled. If promiseOrValue is an immediate value, callback + * will be invoked immediately. + * @param {Function} [errback] callback to be called when promiseOrValue is + * rejected. + * @param {Function} [progressHandler] callback to be called when progress updates + * are issued for promiseOrValue. + * @returns {Promise} a new {@link Promise} that will complete with the return + * value of callback or errback or the completion value of promiseOrValue if + * callback and/or errback is not supplied. + */ + function when(promiseOrValue, callback, errback, progressHandler) { + // Get a trusted promise for the input promiseOrValue, and then + // register promise handlers + return resolve(promiseOrValue).then(callback, errback, progressHandler); + } + + /** + * Returns promiseOrValue if promiseOrValue is a {@link Promise}, a new Promise if + * promiseOrValue is a foreign promise, or a new, already-fulfilled {@link Promise} + * whose value is promiseOrValue if promiseOrValue is an immediate value. + * @memberOf when + * + * @param promiseOrValue {*} + * @returns Guaranteed to return a trusted Promise. If promiseOrValue is a when.js {@link Promise} + * returns promiseOrValue, otherwise, returns a new, already-resolved, when.js {@link Promise} + * whose resolution value is: + * * the resolution value of promiseOrValue if it's a foreign promise, or + * * promiseOrValue if it's a value + */ + function resolve(promiseOrValue) { + var promise, deferred; + + if(promiseOrValue instanceof Promise) { + // It's a when.js promise, so we trust it + promise = promiseOrValue; + + } else { + // It's not a when.js promise. See if it's a foreign promise or a value. + + // Some promises, particularly Q promises, provide a valueOf method that + // attempts to synchronously return the fulfilled value of the promise, or + // returns the unresolved promise itself. Attempting to break a fulfillment + // value out of a promise appears to be necessary to break cycles between + // Q and When attempting to coerce each-other's promises in an infinite loop. + // For promises that do not implement "valueOf", the Object#valueOf is harmless. + // See: https://github.com/kriskowal/q/issues/106 + // IMPORTANT: Must check for a promise here, since valueOf breaks other things + // like Date. + if (isPromise(promiseOrValue) && typeof promiseOrValue.valueOf === 'function') { + promiseOrValue = promiseOrValue.valueOf(); + } + + if(isPromise(promiseOrValue)) { + // It looks like a thenable, but we don't know where it came from, + // so we don't trust its implementation entirely. Introduce a trusted + // middleman when.js promise + deferred = defer(); + + // IMPORTANT: This is the only place when.js should ever call .then() on + // an untrusted promise. + promiseOrValue.then(deferred.resolve, deferred.reject, deferred.progress); + promise = deferred.promise; + + } else { + // It's a value, not a promise. Create a resolved promise for it. + promise = fulfilled(promiseOrValue); + } + } + + return promise; + } + + /** + * Returns a rejected promise for the supplied promiseOrValue. If + * promiseOrValue is a value, it will be the rejection value of the + * returned promise. If promiseOrValue is a promise, its + * completion value will be the rejected value of the returned promise + * @memberOf when + * + * @param promiseOrValue {*} the rejected value of the returned {@link Promise} + * @return {Promise} rejected {@link Promise} + */ + function reject(promiseOrValue) { + return when(promiseOrValue, function(value) { + return rejected(value); + }); + } + + /** + * Trusted Promise constructor. A Promise created from this constructor is + * a trusted when.js promise. Any other duck-typed promise is considered + * untrusted. + * @constructor + * @name Promise + */ + function Promise(then) { + this.then = then; + } + + Promise.prototype = { + /** + * Register a callback that will be called when a promise is + * resolved or rejected. Optionally also register a progress handler. + * Shortcut for .then(alwaysback, alwaysback, progback) + * @memberOf Promise + * @param alwaysback {Function} + * @param progback {Function} + * @return {Promise} + */ + always: function(alwaysback, progback) { + return this.then(alwaysback, alwaysback, progback); + }, + + /** + * Register a rejection handler. Shortcut for .then(null, errback) + * @memberOf Promise + * @param errback {Function} + * @return {Promise} + */ + otherwise: function(errback) { + return this.then(undef, errback); + } + }; + + /** + * Create an already-resolved promise for the supplied value + * @private + * + * @param value anything + * @return {Promise} + */ + function fulfilled(value) { + var p = new Promise(function(callback) { + try { + return resolve(callback ? callback(value) : value); + } catch(e) { + return rejected(e); + } + }); + + return p; + } + + /** + * Create an already-rejected {@link Promise} with the supplied + * rejection reason. + * @private + * + * @param reason rejection reason + * @return {Promise} + */ + function rejected(reason) { + var p = new Promise(function(callback, errback) { + try { + return errback ? resolve(errback(reason)) : rejected(reason); + } catch(e) { + return rejected(e); + } + }); + + return p; + } + + /** + * Creates a new, Deferred with fully isolated resolver and promise parts, + * either or both of which may be given out safely to consumers. + * The Deferred itself has the full API: resolve, reject, progress, and + * then. The resolver has resolve, reject, and progress. The promise + * only has then. + * @memberOf when + * @function + * + * @return {Deferred} + */ + function defer() { + var deferred, promise, handlers, progressHandlers, + _then, _progress, _resolve; + + /** + * The promise for the new deferred + * @type {Promise} + */ + promise = new Promise(then); + + /** + * The full Deferred object, with {@link Promise} and {@link Resolver} parts + * @class Deferred + * @name Deferred + */ + deferred = { + then: then, + resolve: promiseResolve, + reject: promiseReject, + // TODO: Consider renaming progress() to notify() + progress: promiseProgress, + + promise: promise, + + resolver: { + resolve: promiseResolve, + reject: promiseReject, + progress: promiseProgress + } + }; + + handlers = []; + progressHandlers = []; + + /** + * Pre-resolution then() that adds the supplied callback, errback, and progback + * functions to the registered listeners + * @private + * + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @throws {Error} if any argument is not null, undefined, or a Function + */ + _then = function(callback, errback, progback) { + var deferred, progressHandler; + + deferred = defer(); + progressHandler = progback + ? function(update) { + try { + // Allow progress handler to transform progress event + deferred.progress(progback(update)); + } catch(e) { + // Use caught value as progress + deferred.progress(e); + } + } + : deferred.progress; + + handlers.push(function(promise) { + promise.then(callback, errback) + .then(deferred.resolve, deferred.reject, progressHandler); + }); + + progressHandlers.push(progressHandler); + + return deferred.promise; + }; + + /** + * Issue a progress event, notifying all progress listeners + * @private + * @param update {*} progress event payload to pass to all listeners + */ + _progress = function(update) { + processQueue(progressHandlers, update); + return update; + }; + + /** + * Transition from pre-resolution state to post-resolution state, notifying + * all listeners of the resolution or rejection + * @private + * @param completed {Promise} the completed value of this deferred + */ + _resolve = function(completed) { + completed = resolve(completed); + + // Replace _then with one that directly notifies with the result. + _then = completed.then; + // Replace _resolve so that this Deferred can only be completed once + _resolve = resolve; + // Make _progress a noop, to disallow progress for the resolved promise. + _progress = noop; + + // Notify handlers + processQueue(handlers, completed); + + // Free progressHandlers array since we'll never issue progress events + progressHandlers = handlers = undef; + + return completed; + }; + + return deferred; + + /** + * Wrapper to allow _then to be replaced safely + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @return {Promise} new Promise + * @throws {Error} if any argument is not null, undefined, or a Function + */ + function then(callback, errback, progback) { + return _then(callback, errback, progback); + } + + /** + * Wrapper to allow _resolve to be replaced + */ + function promiseResolve(val) { + return _resolve(val); + } + + /** + * Wrapper to allow _resolve to be replaced + */ + function promiseReject(err) { + return _resolve(rejected(err)); + } + + /** + * Wrapper to allow _progress to be replaced + * @param {*} update progress update + */ + function promiseProgress(update) { + return _progress(update); + } + } + + /** + * Determines if promiseOrValue is a promise or not. Uses the feature + * test from http://wiki.commonjs.org/wiki/Promises/A to determine if + * promiseOrValue is a promise. + * + * @param {*} promiseOrValue anything + * @returns {Boolean} true if promiseOrValue is a {@link Promise} + */ + function isPromise(promiseOrValue) { + return promiseOrValue && typeof promiseOrValue.then === 'function'; + } + + /** + * Initiates a competitive race, returning a promise that will resolve when + * howMany of the supplied promisesOrValues have resolved, or will reject when + * it becomes impossible for howMany to resolve, for example, when + * (promisesOrValues.length - howMany) + 1 input promises reject. + * @memberOf when + * + * @param promisesOrValues {Array} array of anything, may contain a mix + * of {@link Promise}s and values + * @param howMany {Number} number of promisesOrValues to resolve + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @returns {Promise} promise that will resolve to an array of howMany values that + * resolved first, or will reject with an array of (promisesOrValues.length - howMany) + 1 + * rejection reasons. + */ + function some(promisesOrValues, howMany, callback, errback, progback) { + + checkCallbacks(2, arguments); + + return when(promisesOrValues, function(promisesOrValues) { + + var toResolve, toReject, values, reasons, deferred, fulfillOne, rejectOne, progress, len, i; + + len = promisesOrValues.length >>> 0; + + toResolve = Math.max(0, Math.min(howMany, len)); + values = []; + + toReject = (len - toResolve) + 1; + reasons = []; + + deferred = defer(); + + // No items in the input, resolve immediately + if (!toResolve) { + deferred.resolve(values); + + } else { + progress = deferred.progress; + + rejectOne = function(reason) { + reasons.push(reason); + if(!--toReject) { + fulfillOne = rejectOne = noop; + deferred.reject(reasons); + } + }; + + fulfillOne = function(val) { + // This orders the values based on promise resolution order + // Another strategy would be to use the original position of + // the corresponding promise. + values.push(val); + + if (!--toResolve) { + fulfillOne = rejectOne = noop; + deferred.resolve(values); + } + }; + + for(i = 0; i < len; ++i) { + if(i in promisesOrValues) { + when(promisesOrValues[i], fulfiller, rejecter, progress); + } + } + } + + return deferred.then(callback, errback, progback); + + function rejecter(reason) { + rejectOne(reason); + } + + function fulfiller(val) { + fulfillOne(val); + } + + }); + } + + /** + * Initiates a competitive race, returning a promise that will resolve when + * any one of the supplied promisesOrValues has resolved or will reject when + * *all* promisesOrValues have rejected. + * @memberOf when + * + * @param promisesOrValues {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @returns {Promise} promise that will resolve to the value that resolved first, or + * will reject with an array of all rejected inputs. + */ + function any(promisesOrValues, callback, errback, progback) { + + function unwrapSingleResult(val) { + return callback ? callback(val[0]) : val[0]; + } + + return some(promisesOrValues, 1, unwrapSingleResult, errback, progback); + } + + /** + * Return a promise that will resolve only once all the supplied promisesOrValues + * have resolved. The resolution value of the returned promise will be an array + * containing the resolution values of each of the promisesOrValues. + * @memberOf when + * + * @param promisesOrValues {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values + * @param [callback] {Function} + * @param [errback] {Function} + * @param [progressHandler] {Function} + * @returns {Promise} + */ + function all(promisesOrValues, callback, errback, progressHandler) { + checkCallbacks(1, arguments); + return map(promisesOrValues, identity).then(callback, errback, progressHandler); + } + + /** + * Joins multiple promises into a single returned promise. + * @memberOf when + * @param {Promise|*} [...promises] two or more promises to join + * @return {Promise} a promise that will fulfill when *all* the input promises + * have fulfilled, or will reject when *any one* of the input promises rejects. + */ + function join(/* ...promises */) { + return map(arguments, identity); + } + + /** + * Traditional map function, similar to `Array.prototype.map()`, but allows + * input to contain {@link Promise}s and/or values, and mapFunc may return + * either a value or a {@link Promise} + * + * @memberOf when + * + * @param promise {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values + * @param mapFunc {Function} mapping function mapFunc(value) which may return + * either a {@link Promise} or value + * @returns {Promise} a {@link Promise} that will resolve to an array containing + * the mapped output values. + */ + function map(promise, mapFunc) { + return when(promise, function(array) { + var results, len, toResolve, resolve, reject, i, d; + + // Since we know the resulting length, we can preallocate the results + // array to avoid array expansions. + toResolve = len = array.length >>> 0; + results = []; + d = defer(); + + if(!toResolve) { + d.resolve(results); + } else { + + reject = d.reject; + resolve = function resolveOne(item, i) { + when(item, mapFunc).then(function(mapped) { + results[i] = mapped; + + if(!--toResolve) { + d.resolve(results); + } + }, reject); + }; + + // Since mapFunc may be async, get all invocations of it into flight + for(i = 0; i < len; i++) { + if(i in array) { + resolve(array[i], i); + } else { + --toResolve; + } + } + + } + + return d.promise; + + }); + } + + /** + * Traditional reduce function, similar to `Array.prototype.reduce()`, but + * input may contain {@link Promise}s and/or values, and reduceFunc + * may return either a value or a {@link Promise}, *and* initialValue may + * be a {@link Promise} for the starting value. + * @memberOf when + * + * @param promise {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values. May also be a {@link Promise} for + * an array. + * @param reduceFunc {Function} reduce function reduce(currentValue, nextValue, index, total), + * where total is the total number of items being reduced, and will be the same + * in each call to reduceFunc. + * @param [initialValue] {*} starting value, or a {@link Promise} for the starting value + * @returns {Promise} that will resolve to the final reduced value + */ + function reduce(promise, reduceFunc /*, initialValue */) { + var args = slice.call(arguments, 1); + + return when(promise, function(array) { + var total; + + total = array.length; + + // Wrap the supplied reduceFunc with one that handles promises and then + // delegates to the supplied. + args[0] = function (current, val, i) { + return when(current, function (c) { + return when(val, function (value) { + return reduceFunc(c, value, i, total); + }); + }); + }; + + return reduceArray.apply(array, args); + }); + } + + /** + * Ensure that resolution of promiseOrValue will complete resolver with the completion + * value of promiseOrValue, or instead with resolveValue if it is provided. + * @memberOf when + * + * @param promiseOrValue + * @param resolver {Resolver} + * @param [resolveValue] anything + * @returns {Promise} + */ + function chain(promiseOrValue, resolver, resolveValue) { + var useResolveValue = arguments.length > 2; + + return when(promiseOrValue, + function(val) { + return resolver.resolve(useResolveValue ? resolveValue : val); + }, + resolver.reject, + resolver.progress + ); + } + + // + // Utility functions + // + + function processQueue(queue, value) { + var handler, i = 0; + + while (handler = queue[i++]) { + handler(value); + } + } + + /** + * Helper that checks arrayOfCallbacks to ensure that each element is either + * a function, or null or undefined. + * @private + * + * @param arrayOfCallbacks {Array} array to check + * @throws {Error} if any element of arrayOfCallbacks is something other than + * a Functions, null, or undefined. + */ + function checkCallbacks(start, arrayOfCallbacks) { + var arg, i = arrayOfCallbacks.length; + + while(i > start) { + arg = arrayOfCallbacks[--i]; + + if (arg != null && typeof arg != 'function') { + throw new Error('arg '+i+' must be a function'); + } + } + } + + /** + * No-Op function used in method replacement + * @private + */ + function noop() {} + + slice = [].slice; + + // ES5 reduce implementation if native not available + // See: http://es5.github.com/#x15.4.4.21 as there are many + // specifics and edge cases. + reduceArray = [].reduce || + function(reduceFunc /*, initialValue */) { + /*jshint maxcomplexity: 7*/ + + // ES5 dictates that reduce.length === 1 + + // This implementation deviates from ES5 spec in the following ways: + // 1. It does not check if reduceFunc is a Callable + + var arr, args, reduced, len, i; + + i = 0; + // This generates a jshint warning, despite being valid + // "Missing 'new' prefix when invoking a constructor." + // See https://github.com/jshint/jshint/issues/392 + arr = Object(this); + len = arr.length >>> 0; + args = arguments; + + // If no initialValue, use first item of array (we know length !== 0 here) + // and adjust i to start at second item + if(args.length <= 1) { + // Skip to the first real element in the array + for(;;) { + if(i in arr) { + reduced = arr[i++]; + break; + } + + // If we reached the end of the array without finding any real + // elements, it's a TypeError + if(++i >= len) { + throw new TypeError(); + } + } + } else { + // If initialValue provided, use it + reduced = args[1]; + } + + // Do the actual reduce + for(;i < len; ++i) { + // Skip holes + if(i in arr) { + reduced = reduceFunc(reduced, arr[i], i, arr); + } + } + + return reduced; + }; + + function identity(x) { + return x; + } + + return when; +}); +})(typeof define == 'function' && define.amd + ? define + : function (deps, factory) { typeof exports === 'object' + ? (module.exports = factory()) + : (this.when = factory()); + } + // Boilerplate for AMD, Node, and browser global +); From a539006d8bda8900284509cd445f5bc6baf632b5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:20:17 +0100 Subject: [PATCH 076/144] js: Add Function.prototype.bind polyfill to make PhantomJS 1.6 happy --- js/test/bind-helper.js | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 js/test/bind-helper.js diff --git a/js/test/bind-helper.js b/js/test/bind-helper.js new file mode 100644 index 00000000..a5a3e0f4 --- /dev/null +++ b/js/test/bind-helper.js @@ -0,0 +1,29 @@ +/* + * PhantomJS 1.6 does not support Function.prototype.bind, so we polyfill it. + * + * Implementation from: + * https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind + */ +if (!Function.prototype.bind) { + Function.prototype.bind = function (oThis) { + if (typeof this !== "function") { + // closest thing possible to the ECMAScript 5 internal IsCallable function + throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable"); + } + + var aArgs = Array.prototype.slice.call(arguments, 1), + fToBind = this, + fNOP = function () {}, + fBound = function () { + return fToBind.apply(this instanceof fNOP && oThis + ? this + : oThis, + aArgs.concat(Array.prototype.slice.call(arguments))); + }; + + fNOP.prototype = this.prototype; + fBound.prototype = new fNOP(); + + return fBound; + }; +} From defc44b747d5e9b00dec38d7db24f7c062a3750b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:21:16 +0100 Subject: [PATCH 077/144] js: Add empty source and test files --- js/src/mopidy.js | 2 ++ js/test/mopidy-test.js | 4 ++++ 2 files changed, 6 insertions(+) create mode 100644 js/src/mopidy.js create mode 100644 js/test/mopidy-test.js diff --git a/js/src/mopidy.js b/js/src/mopidy.js new file mode 100644 index 00000000..5611150d --- /dev/null +++ b/js/src/mopidy.js @@ -0,0 +1,2 @@ +/*global bane:false, when:false*/ + diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js new file mode 100644 index 00000000..aa53dd42 --- /dev/null +++ b/js/test/mopidy-test.js @@ -0,0 +1,4 @@ +/*global when:false, buster:false, assert:false, refute:false, Mopidy:false*/ + +buster.testCase("Mopidy", { +}); From 0a8476352675b51ee0f4374849ed3ead794e7434 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:41:06 +0100 Subject: [PATCH 078/144] js: Add readme --- js/README.rst | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 js/README.rst diff --git a/js/README.rst b/js/README.rst new file mode 100644 index 00000000..3df26baa --- /dev/null +++ b/js/README.rst @@ -0,0 +1,67 @@ +********* +Mopidy.js +********* + +This is the source for the JavaScript library that is installed as a part of +Mopidy's HTTP frontend. The library makes Mopidy's core API available from the +browser, using JSON-RPC messages over a WebSocket to communicate with Mopidy. + + +Getting it +========== + +Regular and minified versions of Mopidy.js, ready for use, is installed +together with Mopidy. When the HTTP frontend is running, the files are +available at: + +- http://localhost:6680/mopidy/mopidy.js +- http://localhost:6680/mopidy/mopidy.min.js + +You may need to adjust hostname and port for your local setup. + +In the source repo, you can find the files at: + +- ``mopidy/frontends/http/data/mopidy.js`` +- ``mopidy/frontends/http/data/mopidy.min.js`` + + +Building from source +==================== + +1. Install `Node.js `_ and npm. There is a PPA if you're + running Ubuntu:: + + sudo apt-get install python-software-properties + sudo add-apt-repository ppa:chris-lea/node.js + sudo apt-get update + sudo apt-get install nodejs npm + +2. Assuming you install from PPA, setup your ``NODE_PATH`` environment variable + to include ``/usr/lib/node_modules``. Add the following to your + ``~/.bashrc`` or equivalent:: + + export NODE_PATH=/usr/lib/node_modules:$NODE_PATH + +3. Install `Buster.js `_ and `Grunt + `_ globally (or locally, and make sure you get their + binaries on your ``PATH``):: + + sudo npm -g install buster grunt + +4. Install the grunt-buster Grunt plugin locally, when in the ``js/`` dir:: + + cd js/ + npm install grunt-buster + +5. Run Grunt to lint, test, concatenate, and minify the source:: + + grunt + + The files in ``../mopidy/frontends/http/data/`` should now be up to date. + + +Development tips +================ + +If you're coding on the JavaScript library, you should know about ``grunt +watch``. It lints and tests the code every time you save a file. From 6a19d379ff26fe7eacba871c05d6e90e884a0671 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:44:50 +0100 Subject: [PATCH 079/144] js: Add PhantomJS installation to readme --- js/README.rst | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/js/README.rst b/js/README.rst index 3df26baa..a68dd9a0 100644 --- a/js/README.rst +++ b/js/README.rst @@ -53,7 +53,15 @@ Building from source cd js/ npm install grunt-buster -5. Run Grunt to lint, test, concatenate, and minify the source:: +5. Install `PhantomJS `_ so that we can run the tests + without a browser:: + + sudo apt-get install phantomjs + + It is packaged in Ubuntu since 12.04, but I haven't tested with versions + older than 1.6 which is the one packaged in Ubuntu 12.10. + +6. Run Grunt to lint, test, concatenate, and minify the source:: grunt From 71f26260d38c4bd8acd32ce235acfef34b2233c0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 29 Nov 2012 00:50:05 +0100 Subject: [PATCH 080/144] js: Add header to bane --- js/lib/bane-0.3.0.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/js/lib/bane-0.3.0.js b/js/lib/bane-0.3.0.js index 53d0ed0d..b82b8cd9 100644 --- a/js/lib/bane-0.3.0.js +++ b/js/lib/bane-0.3.0.js @@ -1,3 +1,11 @@ +/** + * BANE - Browser globals, AMD and Node Events + * + * https://github.com/busterjs/bane + * + * @version 0.3.0 + */ + ((typeof define === "function" && define.amd && function (m) { define(m); }) || (typeof module === "object" && function (m) { module.exports = m(); }) || function (m) { this.bane = m(); } From 6face51e5226dbbdff3959548a4677d6e2a66c6e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 02:04:28 +0100 Subject: [PATCH 081/144] js: Have 'grunt watch' concat and minify as well --- js/grunt.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/grunt.js b/js/grunt.js index 97c3b25f..7be4d882 100644 --- a/js/grunt.js +++ b/js/grunt.js @@ -35,7 +35,7 @@ module.exports = function (grunt) { }, watch: { files: "", - tasks: "lint buster" + tasks: "lint buster concat min" }, jshint: { options: { From 12f60f3a5251408dd86ba538b0d33b8cc07b4040 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 02:05:40 +0100 Subject: [PATCH 082/144] js: Add fully working core API in JavaScript --- js/src/mopidy.js | 274 +++++++++++++++++ js/test/mopidy-test.js | 646 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 919 insertions(+), 1 deletion(-) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 5611150d..233f4a82 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -1,2 +1,276 @@ /*global bane:false, when:false*/ +function Mopidy(settings) { + var mopidy = this; + + mopidy._webSocket = null; + mopidy._pendingRequests = {}; + mopidy._backoffDelayMin = 1000; + mopidy._backoffDelayMax = 64000; + mopidy._backoffDelay = mopidy._backoffDelayMin; + + mopidy._settings = settings || {}; + mopidy._settings.webSocketUrl = + mopidy._settings.webSocketUrl || + "ws://" + document.location.host + "/mopidy/ws/"; + if (mopidy._settings.autoConnect !== false) { + mopidy._settings.autoConnect = true; + } + + bane.createEventEmitter(mopidy); + mopidy._delegateEvents(); + + if (mopidy._settings.autoConnect) { + mopidy._connect(); + } +} + +Mopidy.prototype._delegateEvents = function () { + var mopidy = this; + + // Remove existing event handlers + mopidy.off("websocket:close"); + mopidy.off("websocket:error"); + mopidy.off("websocket:incomingMessage"); + mopidy.off("websocket:open"); + mopidy.off("state:offline"); + + // Register basic set of event handlers + mopidy.on("websocket:close", mopidy._cleanup); + mopidy.on("websocket:error", mopidy._handleWebSocketError); + mopidy.on("websocket:incomingMessage", mopidy._handleMessage); + mopidy.on("websocket:open", mopidy._resetBackoffDelay); + mopidy.on("websocket:open", mopidy._getApiSpec); + mopidy.on("state:offline", mopidy._reconnect); +}; + +Mopidy.prototype._connect = function () { + var mopidy = this; + + if (mopidy._webSocket) { + if (mopidy._webSocket.readyState === WebSocket.OPEN) { + return; + } else { + mopidy._webSocket.close(); + } + } + + mopidy._webSocket = mopidy._settings.webSocket || + new WebSocket(mopidy._settings.webSocketUrl); + + mopidy._webSocket.onclose = function (close) { + mopidy.emit("websocket:close", close); + }; + + mopidy._webSocket.onerror = function (error) { + mopidy.emit("websocket:error", error); + }; + + mopidy._webSocket.onopen = function () { + mopidy.emit("websocket:open"); + }; + + mopidy._webSocket.onmessage = function (message) { + mopidy.emit("websocket:incomingMessage", message); + }; +}; + +Mopidy.prototype._cleanup = function (closeEvent) { + var mopidy = this; + + Object.keys(mopidy._pendingRequests).forEach(function (requestId) { + var resolver = mopidy._pendingRequests[requestId]; + delete mopidy._pendingRequests[requestId]; + resolver.reject({ + message: "WebSocket closed", + closeEvent: closeEvent + }); + }); + + mopidy.emit("state:offline"); +}; + +Mopidy.prototype._reconnect = function () { + var mopidy = this; + + mopidy.emit("reconnectionPending", { + timeToAttempt: mopidy._backoffDelay + }); + + setTimeout(function () { + mopidy.emit("reconnecting"); + mopidy._connect(); + }, mopidy._backoffDelay); + + mopidy._backoffDelay = mopidy._backoffDelay * 2; + if (mopidy._backoffDelay > mopidy._backoffDelayMax) { + mopidy._backoffDelay = mopidy._backoffDelayMax; + } +}; + +Mopidy.prototype._resetBackoffDelay = function () { + var mopidy = this; + + mopidy._backoffDelay = mopidy._backoffDelayMin; +}; + +Mopidy.prototype._handleWebSocketError = function (error) { + console.warn("WebSocket error:", error.stack || error); +}; + +Mopidy.prototype._send = function (message) { + var mopidy = this; + var deferred = when.defer(); + + switch (mopidy._webSocket.readyState) { + case WebSocket.CONNECTING: + deferred.resolver.reject({ + message: "WebSocket is still connecting" + }); + break; + case WebSocket.CLOSING: + deferred.resolver.reject({ + message: "WebSocket is closing" + }); + break; + case WebSocket.CLOSED: + deferred.resolver.reject({ + message: "WebSocket is closed" + }); + break; + default: + message.jsonrpc = "2.0"; + message.id = mopidy._nextRequestId(); + this._pendingRequests[message.id] = deferred.resolver; + this._webSocket.send(JSON.stringify(message)); + mopidy.emit("websocket:outgoingMessage", message); + } + + return deferred.promise; +}; + +Mopidy.prototype._nextRequestId = (function () { + var lastUsed = -1; + return function () { + lastUsed += 1; + return lastUsed; + }; +}()); + +Mopidy.prototype._handleMessage = function (message) { + var mopidy = this; + + try { + var data = JSON.parse(message.data); + if (data.hasOwnProperty("id")) { + mopidy._handleResponse(data); + } else if (data.hasOwnProperty("event")) { + mopidy._handleEvent(data); + } else { + console.warn( + "Unknown message type received. Message was: " + + message.data); + } + } catch (error) { + if (error instanceof SyntaxError) { + console.warn( + "WebSocket message parsing failed. Message was: " + + message.data); + } else { + throw error; + } + } +}; + +Mopidy.prototype._handleResponse = function (responseMessage) { + var mopidy = this; + + if (!mopidy._pendingRequests.hasOwnProperty(responseMessage.id)) { + console.warn( + "Unexpected response received. Message was:", responseMessage); + return; + } + var resolver = mopidy._pendingRequests[responseMessage.id]; + delete mopidy._pendingRequests[responseMessage.id]; + + if (responseMessage.hasOwnProperty("result")) { + resolver.resolve(responseMessage.result); + } else if (responseMessage.hasOwnProperty("error")) { + resolver.reject(responseMessage.error); + console.warn("Server returned error:", responseMessage.error); + } else { + resolver.reject({ + message: "Response without 'result' or 'error' received", + data: {response: responseMessage} + }); + console.warn( + "Response without 'result' or 'error' received. Message was:", + responseMessage); + } +}; + +Mopidy.prototype._handleEvent = function (eventMessage) { + var mopidy = this; + + var type = eventMessage.event; + var data = eventMessage; + delete data.event; + + mopidy.emit("event:" + mopidy._snakeToCamel(type), data); +}; + +Mopidy.prototype._getApiSpec = function () { + var mopidy = this; + + mopidy._send({method: "core.describe"}) + .then(mopidy._createApi.bind(mopidy), mopidy._handleWebSocketError) + .then(null, mopidy._handleWebSocketError); +}; + +Mopidy.prototype._createApi = function (methods) { + var mopidy = this; + + var caller = function (method) { + return function () { + var params = Array.prototype.slice.call(arguments); + return mopidy._send({ + method: method, + params: params + }); + }; + }; + + var getPath = function (fullName) { + var path = fullName.split("."); + if (path.length >= 1 && path[0] === "core") { + path = path.slice(1); + } + return path; + }; + + var createObjects = function (objPath) { + var parentObj = mopidy; + objPath.forEach(function (objName) { + objName = mopidy._snakeToCamel(objName); + parentObj[objName] = parentObj[objName] || {}; + parentObj = parentObj[objName]; + }); + return parentObj; + }; + + var createMethod = function (fullMethodName) { + var methodPath = getPath(fullMethodName); + var methodName = mopidy._snakeToCamel(methodPath.slice(-1)[0]); + var object = createObjects(methodPath.slice(0, -1)); + object[methodName] = caller(fullMethodName); + }; + + Object.keys(methods).forEach(createMethod); + mopidy.emit("state:online"); +}; + +Mopidy.prototype._snakeToCamel = function (name) { + return name.replace(/(_[a-z])/g, function (match) { + return match.toUpperCase().replace("_", ""); + }); +}; diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index aa53dd42..112a2506 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -1,4 +1,648 @@ -/*global when:false, buster:false, assert:false, refute:false, Mopidy:false*/ +/*global buster:false, assert:false, refute:false, when:false, Mopidy:false*/ buster.testCase("Mopidy", { + setUp: function () { + // Sinon.JS doesn't manage to stub PhantomJS' WebSocket implementation, + // so we replace it with a dummy temporarily. + var fakeWebSocket = function () { + return { + send: function () {}, + close: function () {} + }; + }; + fakeWebSocket.CONNECTING = 0; + fakeWebSocket.OPEN = 1; + fakeWebSocket.CLOSING = 2; + fakeWebSocket.CLOSED = 3; + this.realWebSocket = WebSocket; + window.WebSocket = fakeWebSocket; + + this.webSocketConstructorStub = this.stub(window, "WebSocket"); + + this.webSocket = { + close: this.stub(), + send: this.stub() + }; + this.mopidy = new Mopidy({webSocket: this.webSocket}); + }, + + tearDown: function () { + window.WebSocket = this.realWebSocket; + }, + + "constructor": { + "connects when autoConnect is true": function () { + new Mopidy({autoConnect: true}); + + assert.calledOnceWith(this.webSocketConstructorStub, + "ws://" + document.location.host + "/mopidy/ws/"); + }, + + "does not connect when autoConnect is false": function () { + new Mopidy({autoConnect: false}); + + refute.called(this.webSocketConstructorStub); + }, + + "does not connect when passed a WebSocket": function () { + new Mopidy({webSocket: {}}); + + refute.called(this.webSocketConstructorStub); + } + }, + + "._connect": { + "does nothing when the WebSocket is open": function () { + this.webSocket.readyState = WebSocket.OPEN; + var mopidy = new Mopidy({webSocket: this.webSocket}); + + mopidy._connect(); + + refute.called(this.webSocket.close); + refute.called(this.webSocketConstructorStub); + } + }, + + "WebSocket events": { + "emits 'websocket:close' when connection is closed": function () { + var spy = this.spy(); + this.mopidy.off("websocket:close"); + this.mopidy.on("websocket:close", spy); + + var closeEvent = {}; + this.webSocket.onclose(closeEvent); + + assert.calledOnceWith(spy, closeEvent); + }, + + "emits 'websocket:error' when errors occurs": function () { + var spy = this.spy(); + this.mopidy.off("websocket:error"); + this.mopidy.on("websocket:error", spy); + + var errorEvent = {}; + this.webSocket.onerror(errorEvent); + + assert.calledOnceWith(spy, errorEvent); + }, + + "emits 'websocket:incomingMessage' when a message arrives": function () { + var spy = this.spy(); + this.mopidy.off("websocket:incomingMessage"); + this.mopidy.on("websocket:incomingMessage", spy); + + var messageEvent = {data: "this is a message"}; + this.webSocket.onmessage(messageEvent); + + assert.calledOnceWith(spy, messageEvent); + }, + + "emits 'websocket:open' when connection is opened": function () { + var spy = this.spy(); + this.mopidy.off("websocket:open"); + this.mopidy.on("websocket:open", spy); + + this.webSocket.onopen(); + + assert.calledOnceWith(spy); + } + }, + + "._cleanup": { + setUp: function () { + this.mopidy.off("state:offline"); + }, + + "is called on 'websocket:close' event": function () { + var closeEvent = {}; + var stub = this.stub(this.mopidy, "_cleanup"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("websocket:close", closeEvent); + + assert.calledOnceWith(stub, closeEvent); + }, + + "rejects all pending requests": function (done) { + var closeEvent = {}; + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); + + var promise1 = this.mopidy._send({method: "foo"}); + var promise2 = this.mopidy._send({method: "bar"}); + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 2); + + this.mopidy._cleanup(closeEvent); + + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); + when.join(promise1, promise2).then(done(function () { + assert(false, "Promises should be rejected"); + }), done(function (error) { + assert.equals(error.message, "WebSocket closed"); + assert.same(error.closeEvent, closeEvent); + })); + }, + + "emits 'state:offline' event when done": function () { + var spy = this.spy(); + this.mopidy.on("state:offline", spy); + + this.mopidy._cleanup({}); + + assert.calledOnceWith(spy); + } + }, + + "._reconnect": { + "is called when the state changes to offline": function () { + var stub = this.stub(this.mopidy, "_reconnect"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("state:offline"); + + assert.calledOnceWith(stub); + }, + + "tries to connect after an increasing backoff delay": function () { + var clock = this.useFakeTimers(); + var connectStub = this.stub(this.mopidy, "_connect"); + var pendingSpy = this.spy(); + this.mopidy.on("reconnectionPending", pendingSpy); + var reconnectingSpy = this.spy(); + this.mopidy.on("reconnecting", reconnectingSpy); + + refute.called(connectStub); + + this.mopidy._reconnect(); + assert.calledOnceWith(pendingSpy, {timeToAttempt: 1000}); + clock.tick(0); + refute.called(connectStub); + clock.tick(1000); + assert.calledOnceWith(reconnectingSpy); + assert.calledOnce(connectStub); + + pendingSpy.reset(); + reconnectingSpy.reset(); + this.mopidy._reconnect(); + assert.calledOnceWith(pendingSpy, {timeToAttempt: 2000}); + assert.calledOnce(connectStub); + clock.tick(0); + assert.calledOnce(connectStub); + clock.tick(1000); + assert.calledOnce(connectStub); + clock.tick(1000); + assert.calledOnceWith(reconnectingSpy); + assert.calledTwice(connectStub); + + pendingSpy.reset(); + reconnectingSpy.reset(); + this.mopidy._reconnect(); + assert.calledOnceWith(pendingSpy, {timeToAttempt: 4000}); + assert.calledTwice(connectStub); + clock.tick(0); + assert.calledTwice(connectStub); + clock.tick(2000); + assert.calledTwice(connectStub); + clock.tick(2000); + assert.calledOnceWith(reconnectingSpy); + assert.calledThrice(connectStub); + }, + + "tries to connect at least about once per minute": function () { + var clock = this.useFakeTimers(); + var connectStub = this.stub(this.mopidy, "_connect"); + var pendingSpy = this.spy(); + this.mopidy.on("reconnectionPending", pendingSpy); + this.mopidy._backoffDelay = this.mopidy._backoffDelayMax; + + refute.called(connectStub); + + this.mopidy._reconnect(); + assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000}); + clock.tick(0); + refute.called(connectStub); + clock.tick(64000); + assert.calledOnce(connectStub); + + pendingSpy.reset(); + this.mopidy._reconnect(); + assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000}); + assert.calledOnce(connectStub); + clock.tick(0); + assert.calledOnce(connectStub); + clock.tick(64000); + assert.calledTwice(connectStub); + } + }, + + "._resetBackoffDelay": { + "is called on 'websocket:open' event": function () { + var stub = this.stub(this.mopidy, "_resetBackoffDelay"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("websocket:open"); + + assert.calledOnceWith(stub); + }, + + "resets the backoff delay to the minimum value": function () { + this.mopidy._backoffDelay = this.mopidy._backoffDelayMax; + + this.mopidy._resetBackoffDelay(); + + assert.equals(this.mopidy._backoffDelay, + this.mopidy._backoffDelayMin); + } + }, + + "._handleWebSocketError": { + "is called on 'websocket:error' event": function () { + var error = {}; + var stub = this.stub(this.mopidy, "_handleWebSocketError"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("websocket:error", error); + + assert.calledOnceWith(stub, error); + }, + + "without stack logs the error to the console": function () { + var stub = this.stub(console, "warn"); + var error = {}; + + this.mopidy._handleWebSocketError(error); + + assert.calledOnceWith(stub, "WebSocket error:", error); + }, + + "with stack logs the error to the console": function () { + var stub = this.stub(console, "warn"); + var error = {stack: "foo"}; + + this.mopidy._handleWebSocketError(error); + + assert.calledOnceWith(stub, "WebSocket error:", error.stack); + } + }, + + "._send": { + "adds JSON-RPC fields to the message": function () { + this.stub(this.mopidy, "_nextRequestId").returns(1); + var stub = this.stub(JSON, "stringify"); + + this.mopidy._send({method: "foo"}); + + assert.calledOnceWith(stub, { + jsonrpc: "2.0", + id: 1, + method: "foo" + }); + }, + + "adds a resolver to the pending requests queue": function () { + this.stub(this.mopidy, "_nextRequestId").returns(1); + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); + + this.mopidy._send({method: "foo"}); + + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1); + assert.isFunction(this.mopidy._pendingRequests[1].resolve); + }, + + "sends message on the WebSocket": function () { + refute.called(this.mopidy._webSocket.send); + + this.mopidy._send({method: "foo"}); + + assert.calledOnce(this.mopidy._webSocket.send); + }, + + "emits a 'websocket:outgoingMessage' event": function () { + var spy = this.spy(); + this.mopidy.on("websocket:outgoingMessage", spy); + this.stub(this.mopidy, "_nextRequestId").returns(1); + + this.mopidy._send({method: "foo"}); + + assert.calledOnceWith(spy, { + jsonrpc: "2.0", + id: 1, + method: "foo" + }); + }, + + "immediately rejects request if CONNECTING": function (done) { + this.mopidy._webSocket.readyState = WebSocket.CONNECTING; + + var promise = this.mopidy._send({method: "foo"}); + + refute.called(this.mopidy._webSocket.send); + promise.then(done(function () { + assert(false); + }), done(function (error) { + assert.equals( + error.message, "WebSocket is still connecting"); + })); + }, + + "immediately rejects request if CLOSING": function (done) { + this.mopidy._webSocket.readyState = WebSocket.CLOSING; + + var promise = this.mopidy._send({method: "foo"}); + + refute.called(this.mopidy._webSocket.send); + promise.then(done(function () { + assert(false); + }), done(function (error) { + assert.equals( + error.message, "WebSocket is closing"); + })); + }, + + "immediately rejects request if CLOSED": function (done) { + this.mopidy._webSocket.readyState = WebSocket.CLOSED; + + var promise = this.mopidy._send({method: "foo"}); + + refute.called(this.mopidy._webSocket.send); + promise.then(done(function () { + assert(false); + }), done(function (error) { + assert.equals( + error.message, "WebSocket is closed"); + })); + } + }, + + "._nextRequestId": { + "returns an ever increasing ID": function () { + var base = this.mopidy._nextRequestId(); + assert.equals(this.mopidy._nextRequestId(), base + 1); + assert.equals(this.mopidy._nextRequestId(), base + 2); + assert.equals(this.mopidy._nextRequestId(), base + 3); + } + }, + + "._handleMessage": { + "is called on 'websocket:incomingMessage' event": function () { + var messageEvent = {}; + var stub = this.stub(this.mopidy, "_handleMessage"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("websocket:incomingMessage", messageEvent); + + assert.calledOnceWith(stub, messageEvent); + }, + + "passes JSON-RPC responses on to _handleResponse": function () { + var stub = this.stub(this.mopidy, "_handleResponse"); + var message = { + jsonrpc: "2.0", + id: 1, + result: null + }; + var messageEvent = {data: JSON.stringify(message)}; + + this.mopidy._handleMessage(messageEvent); + + assert.calledOnceWith(stub, message); + }, + + "passes events on to _handleEvent": function () { + var stub = this.stub(this.mopidy, "_handleEvent"); + var message = { + event: "track_playback_started", + track: {} + }; + var messageEvent = {data: JSON.stringify(message)}; + + this.mopidy._handleMessage(messageEvent); + + assert.calledOnceWith(stub, message); + }, + + "logs unknown messages": function () { + var stub = this.stub(console, "warn"); + var messageEvent = {data: JSON.stringify({foo: "bar"})}; + + this.mopidy._handleMessage(messageEvent); + + assert.calledOnceWith(stub, + "Unknown message type received. Message was: " + + messageEvent.data); + }, + + "logs JSON parsing errors": function () { + var stub = this.stub(console, "warn"); + var messageEvent = {data: "foobarbaz"}; + + this.mopidy._handleMessage(messageEvent); + + assert.calledOnceWith(stub, + "WebSocket message parsing failed. Message was: " + + messageEvent.data); + } + }, + + "._handleResponse": { + "logs unexpected responses": function () { + var stub = this.stub(console, "warn"); + var responseMessage = { + jsonrpc: "2.0", + id: 1337, + result: null + }; + + this.mopidy._handleResponse(responseMessage); + + assert.calledOnceWith(stub, + "Unexpected response received. Message was:", responseMessage); + }, + + "removes the matching request from the pending queue": function () { + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); + this.mopidy._send({method: "bar"}); + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1); + + this.mopidy._handleResponse({ + jsonrpc: "2.0", + id: Object.keys(this.mopidy._pendingRequests)[0], + result: "baz" + }); + + assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0); + }, + + "resolves requests which get results back": function (done) { + var promise = this.mopidy._send({method: "bar"}); + var responseResult = {}; + var responseMessage = { + jsonrpc: "2.0", + id: Object.keys(this.mopidy._pendingRequests)[0], + result: responseResult + }; + + this.mopidy._handleResponse(responseMessage); + promise.then(done(function (result) { + assert.equals(result, responseResult); + }), done(function () { + assert(false); + })); + }, + + "rejects and logs requests which get errors back": function (done) { + var stub = this.stub(console, "warn"); + var promise = this.mopidy._send({method: "bar"}); + var responseError = {message: "Error", data: {}}; + var responseMessage = { + jsonrpc: "2.0", + id: Object.keys(this.mopidy._pendingRequests)[0], + error: responseError + }; + + this.mopidy._handleResponse(responseMessage); + + assert.calledOnceWith(stub, + "Server returned error:", responseError); + promise.then(done(function () { + assert(false); + }), done(function (error) { + assert.equals(error, responseError); + })); + }, + + "rejects and logs responses without result or error": function (done) { + var stub = this.stub(console, "warn"); + var promise = this.mopidy._send({method: "bar"}); + var responseMessage = { + jsonrpc: "2.0", + id: Object.keys(this.mopidy._pendingRequests)[0] + }; + + this.mopidy._handleResponse(responseMessage); + + assert.calledOnceWith(stub, + "Response without 'result' or 'error' received. Message was:", + responseMessage); + promise.then(done(function () { + assert(false); + }), done(function (error) { + assert.equals( + error.message, + "Response without 'result' or 'error' received"); + assert.equals(error.data.response, responseMessage); + })); + } + }, + + "._handleEvent": { + "emits server side even on Mopidy object": function () { + var spy = this.spy(); + this.mopidy.on(spy); + var track = {}; + var message = { + event: "track_playback_started", + track: track + }; + + this.mopidy._handleEvent(message); + + assert.calledOnceWith(spy, + "event:trackPlaybackStarted", {track: track}); + } + }, + + "._getApiSpec": { + "is called on 'websocket:open' event": function () { + var stub = this.stub(this.mopidy, "_getApiSpec"); + this.mopidy._delegateEvents(); + + this.mopidy.emit("websocket:open"); + + assert.calledOnceWith(stub); + }, + + "gets Api description from server and calls _createApi": function () { + var methods = {}; + var sendStub = this.stub(this.mopidy, "_send"); + sendStub.returns(when.resolve(methods)); + var _createApiStub = this.stub(this.mopidy, "_createApi"); + + this.mopidy._getApiSpec(); + + assert.calledOnceWith(sendStub, {method: "core.describe"}); + assert.calledOnceWith(_createApiStub, methods); + } + }, + + "._createApi": { + "can create an API with methods on the root object": function () { + refute.defined(this.mopidy.hello); + refute.defined(this.mopidy.hi); + + this.mopidy._createApi({ + hello: { + description: "Says hello", + params: [] + }, + hi: { + description: "Says hi", + params: [] + } + }); + + assert.isFunction(this.mopidy.hello); + assert.isFunction(this.mopidy.hi); + }, + + "can create an API with methods on a sub-object": function () { + refute.defined(this.mopidy.hello); + + this.mopidy._createApi({ + "hello.world": { + description: "Says hello to the world", + params: [] + } + }); + + assert.defined(this.mopidy.hello); + assert.isFunction(this.mopidy.hello.world); + }, + + "strips off 'core' from method paths": function () { + refute.defined(this.mopidy.hello); + + this.mopidy._createApi({ + "core.hello.world": { + description: "Says hello to the world", + params: [] + } + }); + + assert.defined(this.mopidy.hello); + assert.isFunction(this.mopidy.hello.world); + }, + + "converts snake_case to camelCase": function () { + refute.defined(this.mopidy.mightyGreetings); + + this.mopidy._createApi({ + "mighty_greetings.hello_world": { + description: "Says hello to the world", + params: [] + } + }); + + assert.defined(this.mopidy.mightyGreetings); + assert.isFunction(this.mopidy.mightyGreetings.helloWorld); + }, + + "triggers 'state:online' event when API is ready for use": function () { + var spy = this.spy(); + this.mopidy.on("state:online", spy); + + this.mopidy._createApi({}); + + assert.calledOnceWith(spy); + } + } }); From fc9ab6053a3c8253d1d0bf04c2ffae8ae99192ac Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 02:18:08 +0100 Subject: [PATCH 083/144] js: Make backoff delay limits settings --- js/src/mopidy.js | 18 +++++++++--------- js/test/mopidy-test.js | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 233f4a82..be1d62be 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -3,12 +3,6 @@ function Mopidy(settings) { var mopidy = this; - mopidy._webSocket = null; - mopidy._pendingRequests = {}; - mopidy._backoffDelayMin = 1000; - mopidy._backoffDelayMax = 64000; - mopidy._backoffDelay = mopidy._backoffDelayMin; - mopidy._settings = settings || {}; mopidy._settings.webSocketUrl = mopidy._settings.webSocketUrl || @@ -16,6 +10,12 @@ function Mopidy(settings) { if (mopidy._settings.autoConnect !== false) { mopidy._settings.autoConnect = true; } + mopidy._settings.backoffDelayMin = mopidy._settings.backoffDelayMin || 1000; + mopidy._settings.backoffDelayMax = mopidy._settings.backoffDelayMax || 64000; + + mopidy._backoffDelay = mopidy._settings.backoffDelayMin; + mopidy._pendingRequests = {}; + mopidy._webSocket = null; bane.createEventEmitter(mopidy); mopidy._delegateEvents(); @@ -103,15 +103,15 @@ Mopidy.prototype._reconnect = function () { }, mopidy._backoffDelay); mopidy._backoffDelay = mopidy._backoffDelay * 2; - if (mopidy._backoffDelay > mopidy._backoffDelayMax) { - mopidy._backoffDelay = mopidy._backoffDelayMax; + if (mopidy._backoffDelay > mopidy._settings.backoffDelayMax) { + mopidy._backoffDelay = mopidy._settings.backoffDelayMax; } }; Mopidy.prototype._resetBackoffDelay = function () { var mopidy = this; - mopidy._backoffDelay = mopidy._backoffDelayMin; + mopidy._backoffDelay = mopidy._settings.backoffDelayMin; }; Mopidy.prototype._handleWebSocketError = function (error) { diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index 112a2506..80155d2f 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -212,7 +212,7 @@ buster.testCase("Mopidy", { var connectStub = this.stub(this.mopidy, "_connect"); var pendingSpy = this.spy(); this.mopidy.on("reconnectionPending", pendingSpy); - this.mopidy._backoffDelay = this.mopidy._backoffDelayMax; + this.mopidy._backoffDelay = this.mopidy._settings.backoffDelayMax; refute.called(connectStub); @@ -250,7 +250,7 @@ buster.testCase("Mopidy", { this.mopidy._resetBackoffDelay(); assert.equals(this.mopidy._backoffDelay, - this.mopidy._backoffDelayMin); + this.mopidy._settings.backoffDelayMin); } }, From 44ad69d7c4486b0dd5f64b62757193fe8fd66c5f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 02:27:01 +0100 Subject: [PATCH 084/144] core: Add missing getter method --- mopidy/core/playback.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index e50de2e7..4941ef0f 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -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 From 3045ac01582a56cb41810b19dc622a7569ca4d0e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 02:41:39 +0100 Subject: [PATCH 085/144] js: Add description and params list to API functions --- js/src/mopidy.js | 2 ++ js/test/mopidy-test.js | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index be1d62be..5dbee57e 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -263,6 +263,8 @@ Mopidy.prototype._createApi = function (methods) { var methodName = mopidy._snakeToCamel(methodPath.slice(-1)[0]); var object = createObjects(methodPath.slice(0, -1)); object[methodName] = caller(fullMethodName); + object[methodName].description = methods[fullMethodName].description; + object[methodName].params = methods[fullMethodName].params; }; Object.keys(methods).forEach(createMethod); diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index 80155d2f..35317378 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -591,7 +591,11 @@ buster.testCase("Mopidy", { }); assert.isFunction(this.mopidy.hello); + assert.equals(this.mopidy.hello.description, "Says hello"); + assert.equals(this.mopidy.hello.params, []); assert.isFunction(this.mopidy.hi); + assert.equals(this.mopidy.hi.description, "Says hi"); + assert.equals(this.mopidy.hi.params, []); }, "can create an API with methods on a sub-object": function () { From 08dcfd56b17d9f1b5817fef5b42466e80c706fbf Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 11:16:42 +0100 Subject: [PATCH 086/144] js: Extract configuration function --- js/src/mopidy.js | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 5dbee57e..4d24ab2f 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -3,15 +3,7 @@ function Mopidy(settings) { var mopidy = this; - mopidy._settings = settings || {}; - mopidy._settings.webSocketUrl = - mopidy._settings.webSocketUrl || - "ws://" + document.location.host + "/mopidy/ws/"; - if (mopidy._settings.autoConnect !== false) { - mopidy._settings.autoConnect = true; - } - mopidy._settings.backoffDelayMin = mopidy._settings.backoffDelayMin || 1000; - mopidy._settings.backoffDelayMax = mopidy._settings.backoffDelayMax || 64000; + mopidy._configure(settings || {}); mopidy._backoffDelay = mopidy._settings.backoffDelayMin; mopidy._pendingRequests = {}; @@ -25,6 +17,25 @@ function Mopidy(settings) { } } +Mopidy.prototype._configure = function (settings) { + var mopidy = this; + + mopidy._settings = settings; + + mopidy._settings.webSocketUrl = + mopidy._settings.webSocketUrl || + "ws://" + document.location.host + "/mopidy/ws/"; + + if (mopidy._settings.autoConnect !== false) { + mopidy._settings.autoConnect = true; + } + + mopidy._settings.backoffDelayMin = + mopidy._settings.backoffDelayMin || 1000; + mopidy._settings.backoffDelayMax = + mopidy._settings.backoffDelayMax || 64000; +}; + Mopidy.prototype._delegateEvents = function () { var mopidy = this; From cb9b0f6ba1d81a39bb3a668c12c8e20f2864e2f1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 11:22:13 +0100 Subject: [PATCH 087/144] js: Simplify configure function --- js/src/mopidy.js | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 4d24ab2f..3ffa8b1a 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -3,7 +3,7 @@ function Mopidy(settings) { var mopidy = this; - mopidy._configure(settings || {}); + mopidy._settings = mopidy._configure(settings || {}); mopidy._backoffDelay = mopidy._settings.backoffDelayMin; mopidy._pendingRequests = {}; @@ -18,22 +18,18 @@ function Mopidy(settings) { } Mopidy.prototype._configure = function (settings) { - var mopidy = this; - - mopidy._settings = settings; - - mopidy._settings.webSocketUrl = - mopidy._settings.webSocketUrl || + settings.webSocketUrl = + settings.webSocketUrl || "ws://" + document.location.host + "/mopidy/ws/"; - if (mopidy._settings.autoConnect !== false) { - mopidy._settings.autoConnect = true; + if (settings.autoConnect !== false) { + settings.autoConnect = true; } - mopidy._settings.backoffDelayMin = - mopidy._settings.backoffDelayMin || 1000; - mopidy._settings.backoffDelayMax = - mopidy._settings.backoffDelayMax || 64000; + settings.backoffDelayMin = settings.backoffDelayMin || 1000; + settings.backoffDelayMax = settings.backoffDelayMax || 64000; + + return settings; }; Mopidy.prototype._delegateEvents = function () { From 0e799c2795589f51eb7908070dd923364be8019d Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 11:23:48 +0100 Subject: [PATCH 088/144] js: Formatting --- js/src/mopidy.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 3ffa8b1a..703c3e9f 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -18,8 +18,7 @@ function Mopidy(settings) { } Mopidy.prototype._configure = function (settings) { - settings.webSocketUrl = - settings.webSocketUrl || + settings.webSocketUrl = settings.webSocketUrl || "ws://" + document.location.host + "/mopidy/ws/"; if (settings.autoConnect !== false) { From 3c56f6cbcee97f77313e3154a06ed719c2967f99 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 12:04:47 +0100 Subject: [PATCH 089/144] js: Use and all over --- js/src/mopidy.js | 168 ++++++++++++++++++++--------------------------- 1 file changed, 73 insertions(+), 95 deletions(-) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 703c3e9f..20bda382 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -1,19 +1,17 @@ /*global bane:false, when:false*/ function Mopidy(settings) { - var mopidy = this; + this._settings = this._configure(settings || {}); - mopidy._settings = mopidy._configure(settings || {}); + this._backoffDelay = this._settings.backoffDelayMin; + this._pendingRequests = {}; + this._webSocket = null; - mopidy._backoffDelay = mopidy._settings.backoffDelayMin; - mopidy._pendingRequests = {}; - mopidy._webSocket = null; + bane.createEventEmitter(this); + this._delegateEvents(); - bane.createEventEmitter(mopidy); - mopidy._delegateEvents(); - - if (mopidy._settings.autoConnect) { - mopidy._connect(); + if (this._settings.autoConnect) { + this._connect(); } } @@ -32,92 +30,82 @@ Mopidy.prototype._configure = function (settings) { }; Mopidy.prototype._delegateEvents = function () { - var mopidy = this; - // Remove existing event handlers - mopidy.off("websocket:close"); - mopidy.off("websocket:error"); - mopidy.off("websocket:incomingMessage"); - mopidy.off("websocket:open"); - mopidy.off("state:offline"); + this.off("websocket:close"); + this.off("websocket:error"); + this.off("websocket:incomingMessage"); + this.off("websocket:open"); + this.off("state:offline"); // Register basic set of event handlers - mopidy.on("websocket:close", mopidy._cleanup); - mopidy.on("websocket:error", mopidy._handleWebSocketError); - mopidy.on("websocket:incomingMessage", mopidy._handleMessage); - mopidy.on("websocket:open", mopidy._resetBackoffDelay); - mopidy.on("websocket:open", mopidy._getApiSpec); - mopidy.on("state:offline", mopidy._reconnect); + this.on("websocket:close", this._cleanup); + this.on("websocket:error", this._handleWebSocketError); + this.on("websocket:incomingMessage", this._handleMessage); + this.on("websocket:open", this._resetBackoffDelay); + this.on("websocket:open", this._getApiSpec); + this.on("state:offline", this._reconnect); }; Mopidy.prototype._connect = function () { - var mopidy = this; - - if (mopidy._webSocket) { - if (mopidy._webSocket.readyState === WebSocket.OPEN) { + if (this._webSocket) { + if (this._webSocket.readyState === WebSocket.OPEN) { return; } else { - mopidy._webSocket.close(); + this._webSocket.close(); } } - mopidy._webSocket = mopidy._settings.webSocket || - new WebSocket(mopidy._settings.webSocketUrl); + this._webSocket = this._settings.webSocket || + new WebSocket(this._settings.webSocketUrl); - mopidy._webSocket.onclose = function (close) { - mopidy.emit("websocket:close", close); - }; + this._webSocket.onclose = function (close) { + this.emit("websocket:close", close); + }.bind(this); - mopidy._webSocket.onerror = function (error) { - mopidy.emit("websocket:error", error); - }; + this._webSocket.onerror = function (error) { + this.emit("websocket:error", error); + }.bind(this); - mopidy._webSocket.onopen = function () { - mopidy.emit("websocket:open"); - }; + this._webSocket.onopen = function () { + this.emit("websocket:open"); + }.bind(this); - mopidy._webSocket.onmessage = function (message) { - mopidy.emit("websocket:incomingMessage", message); - }; + this._webSocket.onmessage = function (message) { + this.emit("websocket:incomingMessage", message); + }.bind(this); }; Mopidy.prototype._cleanup = function (closeEvent) { - var mopidy = this; - - Object.keys(mopidy._pendingRequests).forEach(function (requestId) { - var resolver = mopidy._pendingRequests[requestId]; - delete mopidy._pendingRequests[requestId]; + Object.keys(this._pendingRequests).forEach(function (requestId) { + var resolver = this._pendingRequests[requestId]; + delete this._pendingRequests[requestId]; resolver.reject({ message: "WebSocket closed", closeEvent: closeEvent }); - }); + }.bind(this)); - mopidy.emit("state:offline"); + this.emit("state:offline"); }; Mopidy.prototype._reconnect = function () { - var mopidy = this; - - mopidy.emit("reconnectionPending", { - timeToAttempt: mopidy._backoffDelay + this.emit("reconnectionPending", { + timeToAttempt: this._backoffDelay }); setTimeout(function () { - mopidy.emit("reconnecting"); - mopidy._connect(); - }, mopidy._backoffDelay); + this.emit("reconnecting"); + this._connect(); + }.bind(this), this._backoffDelay); - mopidy._backoffDelay = mopidy._backoffDelay * 2; - if (mopidy._backoffDelay > mopidy._settings.backoffDelayMax) { - mopidy._backoffDelay = mopidy._settings.backoffDelayMax; + this._backoffDelay = this._backoffDelay * 2; + if (this._backoffDelay > this._settings.backoffDelayMax) { + this._backoffDelay = this._settings.backoffDelayMax; } }; Mopidy.prototype._resetBackoffDelay = function () { - var mopidy = this; - - mopidy._backoffDelay = mopidy._settings.backoffDelayMin; + this._backoffDelay = this._settings.backoffDelayMin; }; Mopidy.prototype._handleWebSocketError = function (error) { @@ -125,10 +113,9 @@ Mopidy.prototype._handleWebSocketError = function (error) { }; Mopidy.prototype._send = function (message) { - var mopidy = this; var deferred = when.defer(); - switch (mopidy._webSocket.readyState) { + switch (this._webSocket.readyState) { case WebSocket.CONNECTING: deferred.resolver.reject({ message: "WebSocket is still connecting" @@ -146,10 +133,10 @@ Mopidy.prototype._send = function (message) { break; default: message.jsonrpc = "2.0"; - message.id = mopidy._nextRequestId(); + message.id = this._nextRequestId(); this._pendingRequests[message.id] = deferred.resolver; this._webSocket.send(JSON.stringify(message)); - mopidy.emit("websocket:outgoingMessage", message); + this.emit("websocket:outgoingMessage", message); } return deferred.promise; @@ -164,14 +151,12 @@ Mopidy.prototype._nextRequestId = (function () { }()); Mopidy.prototype._handleMessage = function (message) { - var mopidy = this; - try { var data = JSON.parse(message.data); if (data.hasOwnProperty("id")) { - mopidy._handleResponse(data); + this._handleResponse(data); } else if (data.hasOwnProperty("event")) { - mopidy._handleEvent(data); + this._handleEvent(data); } else { console.warn( "Unknown message type received. Message was: " + @@ -189,15 +174,14 @@ Mopidy.prototype._handleMessage = function (message) { }; Mopidy.prototype._handleResponse = function (responseMessage) { - var mopidy = this; - - if (!mopidy._pendingRequests.hasOwnProperty(responseMessage.id)) { + if (!this._pendingRequests.hasOwnProperty(responseMessage.id)) { console.warn( "Unexpected response received. Message was:", responseMessage); return; } - var resolver = mopidy._pendingRequests[responseMessage.id]; - delete mopidy._pendingRequests[responseMessage.id]; + + var resolver = this._pendingRequests[responseMessage.id]; + delete this._pendingRequests[responseMessage.id]; if (responseMessage.hasOwnProperty("result")) { resolver.resolve(responseMessage.result); @@ -216,35 +200,29 @@ Mopidy.prototype._handleResponse = function (responseMessage) { }; Mopidy.prototype._handleEvent = function (eventMessage) { - var mopidy = this; - var type = eventMessage.event; var data = eventMessage; delete data.event; - mopidy.emit("event:" + mopidy._snakeToCamel(type), data); + this.emit("event:" + this._snakeToCamel(type), data); }; Mopidy.prototype._getApiSpec = function () { - var mopidy = this; - - mopidy._send({method: "core.describe"}) - .then(mopidy._createApi.bind(mopidy), mopidy._handleWebSocketError) - .then(null, mopidy._handleWebSocketError); + this._send({method: "core.describe"}) + .then(this._createApi.bind(this), this._handleWebSocketError) + .then(null, this._handleWebSocketError); }; Mopidy.prototype._createApi = function (methods) { - var mopidy = this; - var caller = function (method) { return function () { var params = Array.prototype.slice.call(arguments); - return mopidy._send({ + return this._send({ method: method, params: params }); - }; - }; + }.bind(this); + }.bind(this); var getPath = function (fullName) { var path = fullName.split("."); @@ -255,26 +233,26 @@ Mopidy.prototype._createApi = function (methods) { }; var createObjects = function (objPath) { - var parentObj = mopidy; + var parentObj = this; objPath.forEach(function (objName) { - objName = mopidy._snakeToCamel(objName); + objName = this._snakeToCamel(objName); parentObj[objName] = parentObj[objName] || {}; parentObj = parentObj[objName]; - }); + }.bind(this)); return parentObj; - }; + }.bind(this); var createMethod = function (fullMethodName) { var methodPath = getPath(fullMethodName); - var methodName = mopidy._snakeToCamel(methodPath.slice(-1)[0]); + var methodName = this._snakeToCamel(methodPath.slice(-1)[0]); var object = createObjects(methodPath.slice(0, -1)); object[methodName] = caller(fullMethodName); object[methodName].description = methods[fullMethodName].description; object[methodName].params = methods[fullMethodName].params; - }; + }.bind(this); Object.keys(methods).forEach(createMethod); - mopidy.emit("state:online"); + this.emit("state:online"); }; Mopidy.prototype._snakeToCamel = function (name) { From 0423d5289b0134f42d1b4c1cadc8be1d4ef95efe Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 18:26:25 +0100 Subject: [PATCH 090/144] http: Mark security and API stability sections as warnings --- mopidy/frontends/http/__init__.py | 40 +++++++++++++++++-------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index fd1d2b01..44096f6f 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -20,14 +20,16 @@ Frontend which lets you control Mopidy through HTTP and WebSockets. 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 + + 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. 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 @@ -40,6 +42,18 @@ directory you want to serve. **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. + 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 @@ -84,16 +98,6 @@ look at the ``core.describe`` response can be helpful. 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 From 90859c903b4ff5b268e5bd177de98065f6799ccb Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 21:57:12 +0100 Subject: [PATCH 091/144] http: Add favicon --- mopidy/frontends/http/actor.py | 5 +++++ mopidy/frontends/http/data/favicon.png | Bin 0 -> 5997 bytes 2 files changed, 5 insertions(+) create mode 100644 mopidy/frontends/http/data/favicon.png diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index 65cf9445..ef6808f0 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -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', diff --git a/mopidy/frontends/http/data/favicon.png b/mopidy/frontends/http/data/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..a214c91fb4fa045dfbb5ea7d5414eda99d6d4dbe GIT binary patch literal 5997 zcmb7Ii9gf-AOCE~(l8n((nL|YBXS?P%~i?Gay3`Tk#h^Vk0NB1G?|c94@G8hjJ}8?Ynb zvrQ9A1z$&Qni^gK4*$Cgn@Y05nWF(lc6R{a*zx}c1Ry>|fs^dP#u$C}(PJXW3l~4U zTP1>1g2DQ>!Fsp-{5<@F0lgp(Y_NwrIt&+l3vFnOvAl7QO8@})2aK=iT)R8G^lbO` zNEct{#41@bxk2hn;p3eT;ohDI&$>l=Ug zg2lsdw6y8&WrG^AJA`}gLT@uxyv8HfXYT@5jWo`&YVO5`<*wc|M35M;obabjvo=xy zs~u)^7kT#s2cQgVdl^SqChXk0vVF+UuQ@7l0vc@iSweU0&?#VpZW+dsdHfipLF{+- zJ@fEP9U$t59}oaNX`#y6&+1T9xPt_~J9|M9J8xAAc5Dj8R>CyV6h_0XK6K&P;0-;a zs3U+aF!{mo=Rb|9bbofh43Gn~x$BNT4u-utD#HB8RtO=CjgRv`*ND6{=3jfKtc(nA zOTwlJj?_5idTf@KmTv9uM>W|>g?^o>bd=)H5v_{LB&YHKwF1!7u6sjGO--YR2iwin zG#V|(`HgZ>nfI_FhRZEYC4-Siorw!=WJW|sGo#Pv#NK@OC!QQ1V+o@$%)cQ6v!u1Z zv%O6rC1r`{oJjBRZXA>0JQ9~iVSHdT#G_%+xgPWL^EeMV)E_dgtUxV558=oGkIq{^ z6DKDRz>W7DgCG>fS$JF5t1{v#_>rqk&a%Y})kJxN4Epy5ibXxe0SzPZrM44+!h~-c z9Bc`50a0Jjmd92161xGFw7}NN*L7HwywhO#NX=2O)Z}gw$;Erj7^_^5(uoyQXSqKK zjw$mR+!j1zg+4o3u5}k^+C*{VTr^mjs5JQ*m7Z{s7DY@YZQgxrYm47DH)M8p_R-9Y zhqOp0d$!Rh$JnKi#ZRkNTdSt`u-1?r7&J@VIE4gDAt5j{Z3HWqHi6jfIVzWSIUez? zyL)qPZm!xeQv_n1HKiWAYzMvYNQJ|T`$yj7>9c|aQv`xlhxgP8T^K}H5~W4J5#1Zd z_{}XWj>)=wbbKMnv9z*M@^$9#+Txu{%2YuV4=|0hIy4gLK?ek^w^ z=Z%uAKt$aLwDqL};SP$ztNH6l97F>Jx0aBQNcOHiUo+xoW%Wp3|_w@2|NWMZc zw0iVgr!W$Y3V3L6a1IqU7(hplvH=NKd14`=!RG=lVzPa!?T^NyVHn!YbOX1A=0oO+ z_t|XRY*4+;J6m5cAxal=Ov-JgwxH<8qT8;C1Xu&CZXR{7!H0w|I|BgUL!POsshKLC z;G9|-sl9(!or1w&j4xiin7+HaOWWMs6iZu6mQ1-`Fvl*!8qsy075mnJ5FjGutjrqr z6B=)bU;VL7%b=q>1C83`jDSK9yV>j4XF{9=f}JeN%7_&M-WS!>)VgoJ`?clRhUQP* z461*suBJ9Z4|hH}b#{M}=YCb{;87jcmR{c!(%Wi#=`qIU@5IDJHXVeG+q=hEM&f%U z$8t@AJCie|j@0bhKag*~z>&ZaPizL!MOPo)d`t?`Mvr?P#&HDO!&li?|tdUBquCU(I`^bwN+5yt?kPt(* zFgzDZi%;P(*?737+bzu)j2F}Vyv(MYrnSGScjlRS?VWnZ=!l3t-#d5KSD)Pec$r_{ zYAeg+yyK!q-9t{vhip4(FXGF}(y*xDs-ZL%?R!|1TD}<$=9^zC2mQFcC)obLEDS~x`vHAH_%+6Gyb*ZqlwDhwas>dk+1^_yy2s^@B zFLd@;vajev(R(Yg{BCJ`OwHRPp)bb#QyUu_i`(1V|Ni{>lj>O|s0Vvt zU5XUm)w|k7uY~ddfOeL666scVPtQUFMGSCNd&cEv$3LaQ)rt}u43T9^ZEH=!>WKZ9 z@t0UsRPD%&Pc`usL)Fv@2{K1&`%~*u_m)lvS~MWMf=_Sfy8_(hM?W-rJQGwe0gH`U z8P|9qo~)^%!PnE%6K`J9b}mI1e>PRSpRp;WdkbTQd`X0Y5syaRo@jo%kco?VyO^1}km-fcei0CVG|jzJ`p8 z)WQ8q_BT@2Hd-mVL%{84dwX0>eu+JK!(aGU&+o2Mr%Tj(>Gm zNXe5TLF@0m>xL~aN}=~7sg-ZBs9X?$v8W)hct0|?M(odKU;b~?jLitLO}R(;fcHn| zH(pP=J>_*EhGe?Zm@PkLnnJEIAJTiVIacV;xjNj;*? zz0`Hcl}k^Sv|{#c_m*zsBh3)376{fG6*e?57rSF)WAn|hrGYUnBlmv!))Jx72-9PuS? zSj+V*6p%62+3aRe?eKsYL%&_IL#;{S} zOY7a9RRO`livNDMrxxeQy7VYL>5j*urjznp6IaJWjAaXjL-X0v#6*!0#Ew5RSr^SR zC*ko;J5=Vbf%dje*W9{^=JPz|OPB0{fHIu@!aS|yn7A>KNPdXauU`svK*5jb(Z0b% z^Tdt6RZ5X1zc!us;;|#h&#{B5hFvQPwJ$s2(jwoq@_lu*bvIY8U@qOHIF>yQl0-!h+DZGFQm&Pi-fOWOn-1k*{{~0BxeMEo>w+F426E&{+3g zQ1MB31D?rnsnBB1Ta5k3JO(8+o)hT_u6F!byPx&p+Xl_~CdH51+x72z*a_)&_M8U% zd!)5t@sr^?6S@yXO{V~WEXe@@K2=IpRaOSsNFs-ad@p&~@ei$R3*Meb8D$0EB3{zB z>Q`S&^cTwn>OTE){d@;z*4s)z+|#xSbh`1yv#M`nBlyPk>zC^4uF&4MSH8|QFZmbn z&Q7rssQq;!N-_HwvtTuTBMd486OAL|a_B1Q;>LJ}zytC5&-67NDSpNo-lKe)Lm%_>m)z1SZrk7m>h z)*;5`W1I4vTeTzSXo`XWkU{SUnJoKuGjprE$S{+uYUs5BlpqQF&>kLFPANVEAw>P# zs#x9MU3aV+GAk|n$^v}8DSsx9Ixsgoo6)>G`=rRabox1(qXTez1~mYp#vf|+L-)ZG zM?IX=I<$*C*bgsuej^~)A>O-gQtvoke(n65yu3Va(_(9O85ebIo08L2A&8tN>;aDW znO0)44kB-Tjaj)hG$?NMD0f`YP(w4ko)t&IRd3DZW#5s~^KmowBn z>}sC%&bd&)<@_2Rj@Smbw+qu;RO$2g-Y^8xjH7^n4{ajP*uh{k;U5jN#G4u#?3sIv zH|5~WEt=xV4>q2Ucl?9Z_o=*Eb&~EZz#4OVBFxLvvj&8-t;-o=KL-aZd@l)o5Ox2> z>E`zQpCJ&{^NL&aFiXNE3NPIP1!}EJCrye;pY(qYxae}Zh=f#-|68d|i@=Vx94xQ* zkPfhZ>J&PMISyG3uh6#Kn7qa9zYG_F9R6vY|C9SxRwy<=odEU6+P2)K&&;0&Iv8Az zJM*V=0Tp}8C=AaZ+%Rqf08!U@HFJ2UY^Dy&ZmtXgruzvD8 zf)?MGn~I@4J~$bD;F12R70v^%Wg~p&e4_IrSyU{%|CLB6K$BfW#uJjHK1oAdy9UbI zD7I%3l9bDme!pd0W8UO^zb$ACirVRFqb(umh)*AMYF=Ifc8)g=A^LjYK2W|vE*0G*=(D$+43c1axjd~mm^A( z3t&wlp<>ZSv1rn0X9yI*tEIA#Pgqq4RzZ5;=HluDWveu`@)eHgO7#3D2k&9{HcxnA zXfI*0Knlq>0V`E7#|eINJ*sV`$f=}PNDel?h=^fFd;3-ZokZ#i6gJ#_(en1(w9|Wd zzf#hvw4S3YN^z~7fHpwb`M14tthYepLUY7snPd4t%9y`0jj_pFR@MP;o0OuG#`(C21sasef`P!j_i_loH z34!P9@tNEIqSu)Jw(m2U%=x&uxO&iu_8lC=lsdn$2vI!=x;PyxWKVgi+MYflh`v@= z@WuU+d5M@{!b4?DRd=QD9UmW4t;?6c*yZxE17FLVQw{b{Awf)_tqlH1g=qgCD8JE; zKUjaB1IC>xDk^G^XwsCX21UrBKJ)2MUBB+3gej7up4Ob%MCwmD4xW*w{sztC!t}KJ z?BrzO_6q*c0W>)pphdCE)n@@t*nGZR3D9v@x;E|4ds8X8Qo9~O4yjYcU2~C zDJmpjdYH@Kfs=BEu z8V_DCK*5Kh2~#6Kp^9RfExd^a5w)SBBQ^N>6gPKBj6Pt*0+g3|N8$0!8=!d0q0{L> zjbrBQ?akhg@MRNQhlyT1EtOL4c0Vohch>(MBwj;2|7GA9#|CQv38Oa(G=Z4bv6Qul1iyg`3`0O6dqjyBIsyr0792b z_m4ee%ZzjkCOcN3H>tu^!H6)kdi0pWF#GiypD{ml{ZQ=B%~;BPG%OK|iu16GBfSb3 z`q;>J{mYMM$$l~LA*HCaXe)N`PNu4-r)o>Y;s zZ4UcA1_W~Qqis#P$+%%7ao(nD=Laz2yu{~QochzxFE*UQ9pH$eDeX7C0t46X`ui^{ z=21hYf*^A~vh=NX7KFmXx%7&T;ne=L3Y2Bd3Yji{Y~$mr;q%gdj3I{}o%$n`OP+CX&ybG3EuUo#;(}*xF8}=-q{N@fnE)U#Ecp97lI{?=4&qUFcX!9| zF!4@Q)YGw%ktbHj`zxW1Br-1TxgdOQ9dG_u!j}`O)%eBT`dl7W0$j`JI7pVvoi%!` zOZ4V|Ah0n>O4+U!2rQ5UbZ4%`=@icIf0J$SZQ%D?inI0=HZ_Bcc4j)d zIk>$i@g7N1IJNyX*jebL2P!=@+5j!qo$&D8G0<*4nb@CIX7};&kpR$bi3-bM-W!=p z=Z+&}>sAi?LgkFJ`ap%>zO=M-f3ElW#J{!9Q+XMgnO@%2!;i#`12MG6F7mZu$CKj= zs58@Go6Dx&QPaV}LCqE%QaL6AzL!SMBRMME6^7zc6_yj`%4FCG*{XY;3s3{(^_nJG zK?v}YhEslK?f~xZdLBtK2b~1z46H=MoQ$&+%E~xEEXkl-I=8~ucn#--RQ3ngm6Q7v z{npgqbkf&iWb%A^ySuO7u(27(A2PSKG-LL6`h}KK1_xTYYM1-|G-sgY9&>Jt5oE`U zfj~Dmw@ivBx+&eB<1^5Ny4cLzgsGd^ zl5-)Nt522SaTSA%H4t&Z#Nc#uJ)8$ae%APXt?_FO6rNV$3UU%K%6bnrGw?AnA05u; zL{Oz2LsEBwBRPlZ5vJ;P=o`CX%qY+b8$+NMQ9o z^00eJ#vyfKAnQh)799+68mYM_Zk$M0QR7b?g17yG+)E7cieC*g+4o^{Wo0cghh?v> z>2psVJM}&3wHVjq^}_GiA1m)ym7&_OSS&jJRN@$C03R?_^c(xGjbyQ?uu{S18m|PP zqK|i#jQ_jnyBpqrq*qOLKbbV&%hX?GeHSSJc*l<_xnztiZ5! qA{5YsB$bahs+|`T{G#5#JXL!B^k3gTJ3QD02aNU2uTXSdWB&*Elu9%J literal 0 HcmV?d00001 From 6238f55ae20a29fea8cf99af05332ab3c2166bc2 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 22:39:34 +0100 Subject: [PATCH 092/144] core: Add CoreListener.on_event() The `on_event()` method is called on all events. By default, it forwards the event to the specific event handler methods. It's also a convenient method to override if you want to handle all events in one place. --- mopidy/core/listener.py | 15 ++++++++++++++- tests/core/listener_test.py | 11 +++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) 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/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) From 2edc884e76fcb7a4bd7d46f73aee96c9c86da16f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 22:41:13 +0100 Subject: [PATCH 093/144] http: Override CoreListener.on_event() instead of the specific event handlers --- mopidy/frontends/http/actor.py | 35 +---------- tests/frontends/http/events_test.py | 94 +---------------------------- 2 files changed, 3 insertions(+), 126 deletions(-) diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index ef6808f0..34f39a4c 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -98,40 +98,7 @@ 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): + def on_event(self, name, **data): event = {} event.update(data) event['event'] = name diff --git a/tests/frontends/http/events_test.py b/tests/frontends/http/events_test.py index 9df4a2b5..d04eb93e 100644 --- a/tests/frontends/http/events_test.py +++ b/tests/frontends/http/events_test.py @@ -15,7 +15,7 @@ class HttpEventsTest(unittest.TestCase): 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 +25,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', - }) From d6a906a723cf045bcb040e556460724dc11e8f98 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 23:15:13 +0100 Subject: [PATCH 094/144] http: No need to copy dict --- mopidy/frontends/http/actor.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index 34f39a4c..8ad0f026 100644 --- a/mopidy/frontends/http/actor.py +++ b/mopidy/frontends/http/actor.py @@ -99,8 +99,7 @@ class HttpFrontend(pykka.ThreadingActor, CoreListener): logger.info('Stopped HTTP server') def on_event(self, name, **data): - event = {} - event.update(data) + event = data event['event'] = name message = json.dumps(event, cls=models.ModelJSONEncoder) cherrypy.engine.publish('websocket-broadcast', TextMessage(message)) From 430d604509e2e45bbc08bd9883901c6b2180c1ec Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 30 Nov 2012 23:50:55 +0100 Subject: [PATCH 095/144] http: Revised the HTTP frontend docs --- mopidy/frontends/http/__init__.py | 48 ++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 44096f6f..d98734b2 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -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,7 +16,9 @@ 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`. @@ -31,16 +34,28 @@ a web server at the port specified by :attr:`mopidy.settings.HTTP_SERVER_PORT`. available from your local network or place it behind a web proxy which takes care or user authentication. You have been warned. -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. + +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 @@ -54,11 +69,19 @@ directory you want to serve. 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` @@ -68,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 @@ -80,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:: @@ -94,7 +121,8 @@ 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. From 9ec53bb3b7104c72bb6d6d17c7c22519ba900a39 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Dec 2012 09:56:28 +0100 Subject: [PATCH 096/144] utils: Include cherrypy in --list-deps --- mopidy/utils/deps.py | 12 ++++++++++++ tests/utils/deps_test.py | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 3c177036..480dc180 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -35,6 +35,7 @@ def format_dependency_list(adapters=None): pylast_info, dbus_info, serial_info, + cherrypy_info, ] lines = [] @@ -189,3 +190,14 @@ 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 diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index 168f98e5..d301cc91 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -27,6 +27,11 @@ try: except ImportError: spotify = False +try: + import cherrypy +except ImportError: + cherrypy = False + from mopidy.utils import deps from tests import unittest @@ -115,3 +120,11 @@ 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']) From 5422d85f5b3b91572361181f60688ac0c0ade58e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Dec 2012 09:58:21 +0100 Subject: [PATCH 097/144] utils: Include ws4py in --list-deps --- mopidy/utils/deps.py | 12 ++++++++++++ tests/utils/deps_test.py | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 480dc180..c83780fb 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -36,6 +36,7 @@ def format_dependency_list(adapters=None): dbus_info, serial_info, cherrypy_info, + ws4py_info, ] lines = [] @@ -201,3 +202,14 @@ def cherrypy_info(): 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 diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index d301cc91..65a1eda1 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -32,6 +32,11 @@ try: except ImportError: cherrypy = False +try: + import ws4py +except ImportError: + ws4py = False + from mopidy.utils import deps from tests import unittest @@ -128,3 +133,11 @@ class DepsTest(unittest.TestCase): 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']) From ec66cae7843a459e093cd60a5aa23e6888b98a9f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Dec 2012 10:48:54 +0100 Subject: [PATCH 098/144] tests: Ignore http tests if cherrypy is missing --- tests/frontends/http/events_test.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/frontends/http/events_test.py b/tests/frontends/http/events_test.py index d04eb93e..631802c4 100644 --- a/tests/frontends/http/events_test.py +++ b/tests/frontends/http/events_test.py @@ -1,14 +1,22 @@ 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()) From 959bd6cd841fccbed68e619ee4fd60293f31e670 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Dec 2012 11:20:44 +0100 Subject: [PATCH 099/144] audio: Add AudioListener.on_event() --- mopidy/audio/listener.py | 15 ++++++++++++++- tests/audio/listener_test.py | 11 +++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py index da5f7b39..f8fedc67 100644 --- a/mopidy/audio/listener.py +++ b/mopidy/audio/listener.py @@ -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): """ diff --git a/tests/audio/listener_test.py b/tests/audio/listener_test.py index b3274721..2c6da8f4 100644 --- a/tests/audio/listener_test.py +++ b/tests/audio/listener_test.py @@ -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() From ac6cecd2f87f543de23da03e8e6af326de859045 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Dec 2012 11:21:06 +0100 Subject: [PATCH 100/144] backends: Add BackendListener.on_event() --- mopidy/backends/listener.py | 15 ++++++++++++++- tests/backends/listener_test.py | 11 ++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/listener.py b/mopidy/backends/listener.py index 30b3291d..d9043079 100644 --- a/mopidy/backends/listener.py +++ b/mopidy/backends/listener.py @@ -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): """ diff --git a/tests/backends/listener_test.py b/tests/backends/listener_test.py index a4df513c..4aee451e 100644 --- a/tests/backends/listener_test.py +++ b/tests/backends/listener_test.py @@ -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() From b62d4d5374829a636e903279dd7addb6b8763c31 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Dec 2012 15:06:20 +0100 Subject: [PATCH 101/144] js: Add console polyfill --- js/lib/console-polyfill.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 js/lib/console-polyfill.js diff --git a/js/lib/console-polyfill.js b/js/lib/console-polyfill.js new file mode 100644 index 00000000..7acfe813 --- /dev/null +++ b/js/lib/console-polyfill.js @@ -0,0 +1,27 @@ +/*jslint browser: true, plusplus: true */ + +/** + * From + * http://skratchdot.com/2012/05/prevent-console-calls-from-throwing-errors/ + */ + +(function (window) { + 'use strict'; + + var i = 0, + emptyFunction = function () {}, + functionNames = [ + 'assert', 'clear', 'count', 'debug', 'dir', + 'dirxml', 'error', 'exception', 'group', 'groupCollapsed', + 'groupEnd', 'info', 'log', 'profile', 'profileEnd', 'table', + 'time', 'timeEnd', 'timeStamp', 'trace', 'warn' + ]; + + // Make sure window.console exists + window.console = window.console || {}; + + // Make sure all functions exist + for (i = 0; i < functionNames.length; i++) { + window.console[functionNames[i]] = window.console[functionNames[i]] || emptyFunction; + } +}(window)); From 7f570de239b0e402ff79c7315529f65816de6f63 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Dec 2012 17:18:06 +0100 Subject: [PATCH 102/144] js: Polyfill console inside our own namespace --- js/lib/console-polyfill.js | 27 --------------------------- js/src/mopidy.js | 23 +++++++++++++++++------ js/test/mopidy-test.js | 14 +++++++------- 3 files changed, 24 insertions(+), 40 deletions(-) delete mode 100644 js/lib/console-polyfill.js diff --git a/js/lib/console-polyfill.js b/js/lib/console-polyfill.js deleted file mode 100644 index 7acfe813..00000000 --- a/js/lib/console-polyfill.js +++ /dev/null @@ -1,27 +0,0 @@ -/*jslint browser: true, plusplus: true */ - -/** - * From - * http://skratchdot.com/2012/05/prevent-console-calls-from-throwing-errors/ - */ - -(function (window) { - 'use strict'; - - var i = 0, - emptyFunction = function () {}, - functionNames = [ - 'assert', 'clear', 'count', 'debug', 'dir', - 'dirxml', 'error', 'exception', 'group', 'groupCollapsed', - 'groupEnd', 'info', 'log', 'profile', 'profileEnd', 'table', - 'time', 'timeEnd', 'timeStamp', 'trace', 'warn' - ]; - - // Make sure window.console exists - window.console = window.console || {}; - - // Make sure all functions exist - for (i = 0; i < functionNames.length; i++) { - window.console[functionNames[i]] = window.console[functionNames[i]] || emptyFunction; - } -}(window)); diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 20bda382..26ac9197 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -2,6 +2,7 @@ function Mopidy(settings) { this._settings = this._configure(settings || {}); + this._console = this._getConsole(); this._backoffDelay = this._settings.backoffDelayMin; this._pendingRequests = {}; @@ -29,6 +30,16 @@ Mopidy.prototype._configure = function (settings) { return settings; }; +Mopidy.prototype._getConsole = function () { + var console = window.console || {}; + + console.log = console.log || function () {}; + console.warn = console.warn || function () {}; + console.error = console.error || function () {}; + + return console; +}; + Mopidy.prototype._delegateEvents = function () { // Remove existing event handlers this.off("websocket:close"); @@ -109,7 +120,7 @@ Mopidy.prototype._resetBackoffDelay = function () { }; Mopidy.prototype._handleWebSocketError = function (error) { - console.warn("WebSocket error:", error.stack || error); + this._console.warn("WebSocket error:", error.stack || error); }; Mopidy.prototype._send = function (message) { @@ -158,13 +169,13 @@ Mopidy.prototype._handleMessage = function (message) { } else if (data.hasOwnProperty("event")) { this._handleEvent(data); } else { - console.warn( + this._console.warn( "Unknown message type received. Message was: " + message.data); } } catch (error) { if (error instanceof SyntaxError) { - console.warn( + this._console.warn( "WebSocket message parsing failed. Message was: " + message.data); } else { @@ -175,7 +186,7 @@ Mopidy.prototype._handleMessage = function (message) { Mopidy.prototype._handleResponse = function (responseMessage) { if (!this._pendingRequests.hasOwnProperty(responseMessage.id)) { - console.warn( + this._console.warn( "Unexpected response received. Message was:", responseMessage); return; } @@ -187,13 +198,13 @@ Mopidy.prototype._handleResponse = function (responseMessage) { resolver.resolve(responseMessage.result); } else if (responseMessage.hasOwnProperty("error")) { resolver.reject(responseMessage.error); - console.warn("Server returned error:", responseMessage.error); + this._console.warn("Server returned error:", responseMessage.error); } else { resolver.reject({ message: "Response without 'result' or 'error' received", data: {response: responseMessage} }); - console.warn( + this._console.warn( "Response without 'result' or 'error' received. Message was:", responseMessage); } diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index 35317378..8525e09b 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -266,7 +266,7 @@ buster.testCase("Mopidy", { }, "without stack logs the error to the console": function () { - var stub = this.stub(console, "warn"); + var stub = this.stub(this.mopidy._console, "warn"); var error = {}; this.mopidy._handleWebSocketError(error); @@ -275,7 +275,7 @@ buster.testCase("Mopidy", { }, "with stack logs the error to the console": function () { - var stub = this.stub(console, "warn"); + var stub = this.stub(this.mopidy._console, "warn"); var error = {stack: "foo"}; this.mopidy._handleWebSocketError(error); @@ -421,7 +421,7 @@ buster.testCase("Mopidy", { }, "logs unknown messages": function () { - var stub = this.stub(console, "warn"); + var stub = this.stub(this.mopidy._console, "warn"); var messageEvent = {data: JSON.stringify({foo: "bar"})}; this.mopidy._handleMessage(messageEvent); @@ -432,7 +432,7 @@ buster.testCase("Mopidy", { }, "logs JSON parsing errors": function () { - var stub = this.stub(console, "warn"); + var stub = this.stub(this.mopidy._console, "warn"); var messageEvent = {data: "foobarbaz"}; this.mopidy._handleMessage(messageEvent); @@ -445,7 +445,7 @@ buster.testCase("Mopidy", { "._handleResponse": { "logs unexpected responses": function () { - var stub = this.stub(console, "warn"); + var stub = this.stub(this.mopidy._console, "warn"); var responseMessage = { jsonrpc: "2.0", id: 1337, @@ -490,7 +490,7 @@ buster.testCase("Mopidy", { }, "rejects and logs requests which get errors back": function (done) { - var stub = this.stub(console, "warn"); + var stub = this.stub(this.mopidy._console, "warn"); var promise = this.mopidy._send({method: "bar"}); var responseError = {message: "Error", data: {}}; var responseMessage = { @@ -511,7 +511,7 @@ buster.testCase("Mopidy", { }, "rejects and logs responses without result or error": function (done) { - var stub = this.stub(console, "warn"); + var stub = this.stub(this.mopidy._console, "warn"); var promise = this.mopidy._send({method: "bar"}); var responseMessage = { jsonrpc: "2.0", From 3f8b5affe0267a348ae27c2c660251d2d547a4ab Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Dec 2012 17:24:34 +0100 Subject: [PATCH 103/144] tests: Ignore http tests if ws4py is missing --- tests/frontends/http/events_test.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/frontends/http/events_test.py b/tests/frontends/http/events_test.py index 631802c4..5c064e93 100644 --- a/tests/frontends/http/events_test.py +++ b/tests/frontends/http/events_test.py @@ -4,6 +4,10 @@ try: import cherrypy except ImportError: cherrypy = False +try: + import ws4py +except ImportError: + ws4py = False import mock from mopidy.exceptions import OptionalDependencyError @@ -16,6 +20,7 @@ from tests import unittest @unittest.skipUnless(cherrypy, 'cherrypy not found') +@unittest.skipUnless(ws4py, 'ws4py not found') @mock.patch('cherrypy.engine.publish') class HttpEventsTest(unittest.TestCase): def setUp(self): From c54a9c3260644999bdc64a48787197a51b627130 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 1 Dec 2012 17:31:27 +0100 Subject: [PATCH 104/144] docs: Add HTTP frontend to changelog --- docs/changes.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 6fca3cbf..81bb8592 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -7,9 +7,11 @@ This change log is used to track all major changes to Mopidy. v0.10.0 (in development) ======================== -**Changes** +**HTTP frontend** -- None yet +- Added new optional HTTP frontend which exposes Mopidy's core API through + JSON-RPC 2.0 messages over a WebSocket. See :ref:`http-frontend` for further + details. **Bug fixes** From 4889d2a928034217ad0d24d947cc6550ee773630 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 2 Dec 2012 15:43:59 +0100 Subject: [PATCH 105/144] scanner: Ignore invalid dates --- docs/changes.rst | 3 +++ mopidy/scanner.py | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 81bb8592..a8a4fb0e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -19,6 +19,9 @@ v0.10.0 (in development) ``glib``. The bug can be worked around by overriding the settings that includes offending `$XDG_` variables. +- Make ``mopidy-scan`` ignore invalid dates, e.g. dates in years outside the + range 1-9999. + v0.9.0 (2012-11-21) =================== diff --git a/mopidy/scanner.py b/mopidy/scanner.py index d84c262c..829b501a 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -62,8 +62,12 @@ def translator(data): if gst.TAG_DATE in data and data[gst.TAG_DATE]: date = data[gst.TAG_DATE] - date = datetime.date(date.year, date.month, date.day) - track_kwargs['date'] = date + try: + date = datetime.date(date.year, date.month, date.day) + except ValueError: + pass # Ignore invalid dates + else: + track_kwargs['date'] = date.isoformat() _retrieve(gst.TAG_TITLE, 'name', track_kwargs) _retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs) From 3473b9c90235585de5c6827e29a6ceec845baf71 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 2 Dec 2012 15:45:54 +0100 Subject: [PATCH 106/144] scanner: Update test case --- tests/scanner_test.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/scanner_test.py b/tests/scanner_test.py index 08784458..4a703df8 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -1,7 +1,5 @@ from __future__ import unicode_literals -from datetime import date - from mopidy.scanner import Scanner, translator from mopidy.models import Track, Artist, Album @@ -53,7 +51,7 @@ class TranslatorTest(unittest.TestCase): self.track = { 'uri': 'uri', 'name': 'trackname', - 'date': date(2006, 1, 1), + 'date': '2006-01-01', 'track_no': 1, 'length': 4531, 'musicbrainz_id': 'mbtrackid', From 7f2e2a8faa71430bfb7aa80c618a512b5f78860f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sun, 2 Dec 2012 15:48:02 +0100 Subject: [PATCH 107/144] scanner: Test invalid date --- tests/scanner_test.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/scanner_test.py b/tests/scanner_test.py index 4a703df8..92e9a269 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -127,6 +127,11 @@ class TranslatorTest(unittest.TestCase): del self.track['date'] self.check() + def test_invalid_date(self): + self.data['date'] = FakeGstDate(65535, 1, 1) + del self.track['date'] + self.check() + class ScannerTest(unittest.TestCase): def setUp(self): From accf018ae811ebbfc5e81dded3379f3575e0c2ce Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 00:06:34 +0100 Subject: [PATCH 108/144] js: Add concatenated and minified versions of mopidy.js --- mopidy/frontends/http/data/mopidy.js | 1160 ++++++++++++++++++++++ mopidy/frontends/http/data/mopidy.min.js | 5 + 2 files changed, 1165 insertions(+) create mode 100644 mopidy/frontends/http/data/mopidy.js create mode 100644 mopidy/frontends/http/data/mopidy.min.js diff --git a/mopidy/frontends/http/data/mopidy.js b/mopidy/frontends/http/data/mopidy.js new file mode 100644 index 00000000..29f076c5 --- /dev/null +++ b/mopidy/frontends/http/data/mopidy.js @@ -0,0 +1,1160 @@ +/*! Mopidy.js - built 2012-12-03 + * http://www.mopidy.com/ + * Copyright (c) 2012 Stein Magnus Jodal and contributors + * Licensed under the Apache License, Version 2.0 */ + +/** + * BANE - Browser globals, AMD and Node Events + * + * https://github.com/busterjs/bane + * + * @version 0.3.0 + */ + +((typeof define === "function" && define.amd && function (m) { define(m); }) || + (typeof module === "object" && function (m) { module.exports = m(); }) || + function (m) { this.bane = m(); } +)(function () { + "use strict"; + var slice = Array.prototype.slice; + + function handleError(event, error, errbacks) { + var i, l = errbacks.length; + if (l > 0) { + for (i = 0; i < l; ++i) { errbacks[i](event, error); } + return; + } + setTimeout(function () { + error.message = event + " listener threw error: " + error.message; + throw error; + }, 0); + } + + function assertFunction(fn) { + if (typeof fn !== "function") { + throw new TypeError("Listener is not function"); + } + return fn; + } + + function supervisors(object) { + if (!object.supervisors) { object.supervisors = []; } + return object.supervisors; + } + + function listeners(object, event) { + if (!object.listeners) { object.listeners = {}; } + if (event && !object.listeners[event]) { object.listeners[event] = []; } + return event ? object.listeners[event] : object.listeners; + } + + /** + * @signature var emitter = bane.createEmitter([object]); + * + * Create a new event emitter. If an object is passed, it will be modified + * by adding the event emitter methods (see below). + */ + function createEventEmitter(object) { + object = object || {}; + + function notifyListener(event, listener, args) { + try { + listener.listener.apply(listener.thisp || object, args); + } catch (e) { + handleError(event, e, object.errbacks || []); + } + } + + object.on = function (event, listener, thisp) { + if (typeof event === "function") { + return supervisors(this).push({ + listener: event, + thisp: listener + }); + } + listeners(this, event).push({ + listener: assertFunction(listener), + thisp: thisp + }); + }; + + object.off = function (event, listener) { + var fns, i, l; + if (typeof event === "function") { + fns = supervisors(this); + listener = event; + } else { + fns = listeners(this, event); + } + if (!listener) { + fns.splice(0, fns.length); + return; + } + for (i = 0, l = fns.length; i < l; ++i) { + if (fns[i].listener === listener) { + fns.splice(i, 1); + return; + } + } + }; + + object.once = function (event, listener, thisp) { + var wrapper = function () { + object.off(event, wrapper); + listener.apply(this, arguments); + }; + + object.on(event, wrapper, thisp); + }; + + object.bind = function (object, events) { + var prop, i, l; + if (!events) { + for (prop in object) { + if (typeof object[prop] === "function") { + this.on(prop, object[prop], object); + } + } + } else { + for (i = 0, l = events.length; i < l; ++i) { + if (typeof object[events[i]] === "function") { + this.on(events[i], object[events[i]], object); + } else { + throw new Error("No such method " + events[i]); + } + } + } + return object; + }; + + object.emit = function (event) { + var toNotify = listeners(this, event).slice(); + var args = slice.call(arguments, 1), i, l; + + for (i = 0, l = toNotify.length; i < l; i++) { + notifyListener(event, toNotify[i], args); + } + + toNotify = supervisors(this); + args = slice.call(arguments); + for (i = 0, l = toNotify.length; i < l; ++i) { + notifyListener(event, toNotify[i], args); + } + }; + + object.errback = function (listener) { + if (!this.errbacks) { this.errbacks = []; } + this.errbacks.push(assertFunction(listener)); + }; + + return object; + } + + return { createEventEmitter: createEventEmitter }; +}); + +/** @license MIT License (c) copyright B Cavalier & J Hann */ + +/** + * A lightweight CommonJS Promises/A and when() implementation + * when is part of the cujo.js family of libraries (http://cujojs.com/) + * + * Licensed under the MIT License at: + * http://www.opensource.org/licenses/mit-license.php + * + * @version 1.6.1 + */ + +(function(define) { 'use strict'; +define(['module'], function () { + var reduceArray, slice, undef; + + // + // Public API + // + + when.defer = defer; // Create a deferred + when.resolve = resolve; // Create a resolved promise + when.reject = reject; // Create a rejected promise + + when.join = join; // Join 2 or more promises + + when.all = all; // Resolve a list of promises + when.some = some; // Resolve a sub-set of promises + when.any = any; // Resolve one promise in a list + + when.map = map; // Array.map() for promises + when.reduce = reduce; // Array.reduce() for promises + + when.chain = chain; // Make a promise trigger another resolver + + when.isPromise = isPromise; // Determine if a thing is a promise + + /** + * Register an observer for a promise or immediate value. + * @function + * @name when + * @namespace + * + * @param promiseOrValue {*} + * @param {Function} [callback] callback to be called when promiseOrValue is + * successfully fulfilled. If promiseOrValue is an immediate value, callback + * will be invoked immediately. + * @param {Function} [errback] callback to be called when promiseOrValue is + * rejected. + * @param {Function} [progressHandler] callback to be called when progress updates + * are issued for promiseOrValue. + * @returns {Promise} a new {@link Promise} that will complete with the return + * value of callback or errback or the completion value of promiseOrValue if + * callback and/or errback is not supplied. + */ + function when(promiseOrValue, callback, errback, progressHandler) { + // Get a trusted promise for the input promiseOrValue, and then + // register promise handlers + return resolve(promiseOrValue).then(callback, errback, progressHandler); + } + + /** + * Returns promiseOrValue if promiseOrValue is a {@link Promise}, a new Promise if + * promiseOrValue is a foreign promise, or a new, already-fulfilled {@link Promise} + * whose value is promiseOrValue if promiseOrValue is an immediate value. + * @memberOf when + * + * @param promiseOrValue {*} + * @returns Guaranteed to return a trusted Promise. If promiseOrValue is a when.js {@link Promise} + * returns promiseOrValue, otherwise, returns a new, already-resolved, when.js {@link Promise} + * whose resolution value is: + * * the resolution value of promiseOrValue if it's a foreign promise, or + * * promiseOrValue if it's a value + */ + function resolve(promiseOrValue) { + var promise, deferred; + + if(promiseOrValue instanceof Promise) { + // It's a when.js promise, so we trust it + promise = promiseOrValue; + + } else { + // It's not a when.js promise. See if it's a foreign promise or a value. + + // Some promises, particularly Q promises, provide a valueOf method that + // attempts to synchronously return the fulfilled value of the promise, or + // returns the unresolved promise itself. Attempting to break a fulfillment + // value out of a promise appears to be necessary to break cycles between + // Q and When attempting to coerce each-other's promises in an infinite loop. + // For promises that do not implement "valueOf", the Object#valueOf is harmless. + // See: https://github.com/kriskowal/q/issues/106 + // IMPORTANT: Must check for a promise here, since valueOf breaks other things + // like Date. + if (isPromise(promiseOrValue) && typeof promiseOrValue.valueOf === 'function') { + promiseOrValue = promiseOrValue.valueOf(); + } + + if(isPromise(promiseOrValue)) { + // It looks like a thenable, but we don't know where it came from, + // so we don't trust its implementation entirely. Introduce a trusted + // middleman when.js promise + deferred = defer(); + + // IMPORTANT: This is the only place when.js should ever call .then() on + // an untrusted promise. + promiseOrValue.then(deferred.resolve, deferred.reject, deferred.progress); + promise = deferred.promise; + + } else { + // It's a value, not a promise. Create a resolved promise for it. + promise = fulfilled(promiseOrValue); + } + } + + return promise; + } + + /** + * Returns a rejected promise for the supplied promiseOrValue. If + * promiseOrValue is a value, it will be the rejection value of the + * returned promise. If promiseOrValue is a promise, its + * completion value will be the rejected value of the returned promise + * @memberOf when + * + * @param promiseOrValue {*} the rejected value of the returned {@link Promise} + * @return {Promise} rejected {@link Promise} + */ + function reject(promiseOrValue) { + return when(promiseOrValue, function(value) { + return rejected(value); + }); + } + + /** + * Trusted Promise constructor. A Promise created from this constructor is + * a trusted when.js promise. Any other duck-typed promise is considered + * untrusted. + * @constructor + * @name Promise + */ + function Promise(then) { + this.then = then; + } + + Promise.prototype = { + /** + * Register a callback that will be called when a promise is + * resolved or rejected. Optionally also register a progress handler. + * Shortcut for .then(alwaysback, alwaysback, progback) + * @memberOf Promise + * @param alwaysback {Function} + * @param progback {Function} + * @return {Promise} + */ + always: function(alwaysback, progback) { + return this.then(alwaysback, alwaysback, progback); + }, + + /** + * Register a rejection handler. Shortcut for .then(null, errback) + * @memberOf Promise + * @param errback {Function} + * @return {Promise} + */ + otherwise: function(errback) { + return this.then(undef, errback); + } + }; + + /** + * Create an already-resolved promise for the supplied value + * @private + * + * @param value anything + * @return {Promise} + */ + function fulfilled(value) { + var p = new Promise(function(callback) { + try { + return resolve(callback ? callback(value) : value); + } catch(e) { + return rejected(e); + } + }); + + return p; + } + + /** + * Create an already-rejected {@link Promise} with the supplied + * rejection reason. + * @private + * + * @param reason rejection reason + * @return {Promise} + */ + function rejected(reason) { + var p = new Promise(function(callback, errback) { + try { + return errback ? resolve(errback(reason)) : rejected(reason); + } catch(e) { + return rejected(e); + } + }); + + return p; + } + + /** + * Creates a new, Deferred with fully isolated resolver and promise parts, + * either or both of which may be given out safely to consumers. + * The Deferred itself has the full API: resolve, reject, progress, and + * then. The resolver has resolve, reject, and progress. The promise + * only has then. + * @memberOf when + * @function + * + * @return {Deferred} + */ + function defer() { + var deferred, promise, handlers, progressHandlers, + _then, _progress, _resolve; + + /** + * The promise for the new deferred + * @type {Promise} + */ + promise = new Promise(then); + + /** + * The full Deferred object, with {@link Promise} and {@link Resolver} parts + * @class Deferred + * @name Deferred + */ + deferred = { + then: then, + resolve: promiseResolve, + reject: promiseReject, + // TODO: Consider renaming progress() to notify() + progress: promiseProgress, + + promise: promise, + + resolver: { + resolve: promiseResolve, + reject: promiseReject, + progress: promiseProgress + } + }; + + handlers = []; + progressHandlers = []; + + /** + * Pre-resolution then() that adds the supplied callback, errback, and progback + * functions to the registered listeners + * @private + * + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @throws {Error} if any argument is not null, undefined, or a Function + */ + _then = function(callback, errback, progback) { + var deferred, progressHandler; + + deferred = defer(); + progressHandler = progback + ? function(update) { + try { + // Allow progress handler to transform progress event + deferred.progress(progback(update)); + } catch(e) { + // Use caught value as progress + deferred.progress(e); + } + } + : deferred.progress; + + handlers.push(function(promise) { + promise.then(callback, errback) + .then(deferred.resolve, deferred.reject, progressHandler); + }); + + progressHandlers.push(progressHandler); + + return deferred.promise; + }; + + /** + * Issue a progress event, notifying all progress listeners + * @private + * @param update {*} progress event payload to pass to all listeners + */ + _progress = function(update) { + processQueue(progressHandlers, update); + return update; + }; + + /** + * Transition from pre-resolution state to post-resolution state, notifying + * all listeners of the resolution or rejection + * @private + * @param completed {Promise} the completed value of this deferred + */ + _resolve = function(completed) { + completed = resolve(completed); + + // Replace _then with one that directly notifies with the result. + _then = completed.then; + // Replace _resolve so that this Deferred can only be completed once + _resolve = resolve; + // Make _progress a noop, to disallow progress for the resolved promise. + _progress = noop; + + // Notify handlers + processQueue(handlers, completed); + + // Free progressHandlers array since we'll never issue progress events + progressHandlers = handlers = undef; + + return completed; + }; + + return deferred; + + /** + * Wrapper to allow _then to be replaced safely + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @return {Promise} new Promise + * @throws {Error} if any argument is not null, undefined, or a Function + */ + function then(callback, errback, progback) { + return _then(callback, errback, progback); + } + + /** + * Wrapper to allow _resolve to be replaced + */ + function promiseResolve(val) { + return _resolve(val); + } + + /** + * Wrapper to allow _resolve to be replaced + */ + function promiseReject(err) { + return _resolve(rejected(err)); + } + + /** + * Wrapper to allow _progress to be replaced + * @param {*} update progress update + */ + function promiseProgress(update) { + return _progress(update); + } + } + + /** + * Determines if promiseOrValue is a promise or not. Uses the feature + * test from http://wiki.commonjs.org/wiki/Promises/A to determine if + * promiseOrValue is a promise. + * + * @param {*} promiseOrValue anything + * @returns {Boolean} true if promiseOrValue is a {@link Promise} + */ + function isPromise(promiseOrValue) { + return promiseOrValue && typeof promiseOrValue.then === 'function'; + } + + /** + * Initiates a competitive race, returning a promise that will resolve when + * howMany of the supplied promisesOrValues have resolved, or will reject when + * it becomes impossible for howMany to resolve, for example, when + * (promisesOrValues.length - howMany) + 1 input promises reject. + * @memberOf when + * + * @param promisesOrValues {Array} array of anything, may contain a mix + * of {@link Promise}s and values + * @param howMany {Number} number of promisesOrValues to resolve + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @returns {Promise} promise that will resolve to an array of howMany values that + * resolved first, or will reject with an array of (promisesOrValues.length - howMany) + 1 + * rejection reasons. + */ + function some(promisesOrValues, howMany, callback, errback, progback) { + + checkCallbacks(2, arguments); + + return when(promisesOrValues, function(promisesOrValues) { + + var toResolve, toReject, values, reasons, deferred, fulfillOne, rejectOne, progress, len, i; + + len = promisesOrValues.length >>> 0; + + toResolve = Math.max(0, Math.min(howMany, len)); + values = []; + + toReject = (len - toResolve) + 1; + reasons = []; + + deferred = defer(); + + // No items in the input, resolve immediately + if (!toResolve) { + deferred.resolve(values); + + } else { + progress = deferred.progress; + + rejectOne = function(reason) { + reasons.push(reason); + if(!--toReject) { + fulfillOne = rejectOne = noop; + deferred.reject(reasons); + } + }; + + fulfillOne = function(val) { + // This orders the values based on promise resolution order + // Another strategy would be to use the original position of + // the corresponding promise. + values.push(val); + + if (!--toResolve) { + fulfillOne = rejectOne = noop; + deferred.resolve(values); + } + }; + + for(i = 0; i < len; ++i) { + if(i in promisesOrValues) { + when(promisesOrValues[i], fulfiller, rejecter, progress); + } + } + } + + return deferred.then(callback, errback, progback); + + function rejecter(reason) { + rejectOne(reason); + } + + function fulfiller(val) { + fulfillOne(val); + } + + }); + } + + /** + * Initiates a competitive race, returning a promise that will resolve when + * any one of the supplied promisesOrValues has resolved or will reject when + * *all* promisesOrValues have rejected. + * @memberOf when + * + * @param promisesOrValues {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values + * @param [callback] {Function} resolution handler + * @param [errback] {Function} rejection handler + * @param [progback] {Function} progress handler + * @returns {Promise} promise that will resolve to the value that resolved first, or + * will reject with an array of all rejected inputs. + */ + function any(promisesOrValues, callback, errback, progback) { + + function unwrapSingleResult(val) { + return callback ? callback(val[0]) : val[0]; + } + + return some(promisesOrValues, 1, unwrapSingleResult, errback, progback); + } + + /** + * Return a promise that will resolve only once all the supplied promisesOrValues + * have resolved. The resolution value of the returned promise will be an array + * containing the resolution values of each of the promisesOrValues. + * @memberOf when + * + * @param promisesOrValues {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values + * @param [callback] {Function} + * @param [errback] {Function} + * @param [progressHandler] {Function} + * @returns {Promise} + */ + function all(promisesOrValues, callback, errback, progressHandler) { + checkCallbacks(1, arguments); + return map(promisesOrValues, identity).then(callback, errback, progressHandler); + } + + /** + * Joins multiple promises into a single returned promise. + * @memberOf when + * @param {Promise|*} [...promises] two or more promises to join + * @return {Promise} a promise that will fulfill when *all* the input promises + * have fulfilled, or will reject when *any one* of the input promises rejects. + */ + function join(/* ...promises */) { + return map(arguments, identity); + } + + /** + * Traditional map function, similar to `Array.prototype.map()`, but allows + * input to contain {@link Promise}s and/or values, and mapFunc may return + * either a value or a {@link Promise} + * + * @memberOf when + * + * @param promise {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values + * @param mapFunc {Function} mapping function mapFunc(value) which may return + * either a {@link Promise} or value + * @returns {Promise} a {@link Promise} that will resolve to an array containing + * the mapped output values. + */ + function map(promise, mapFunc) { + return when(promise, function(array) { + var results, len, toResolve, resolve, reject, i, d; + + // Since we know the resulting length, we can preallocate the results + // array to avoid array expansions. + toResolve = len = array.length >>> 0; + results = []; + d = defer(); + + if(!toResolve) { + d.resolve(results); + } else { + + reject = d.reject; + resolve = function resolveOne(item, i) { + when(item, mapFunc).then(function(mapped) { + results[i] = mapped; + + if(!--toResolve) { + d.resolve(results); + } + }, reject); + }; + + // Since mapFunc may be async, get all invocations of it into flight + for(i = 0; i < len; i++) { + if(i in array) { + resolve(array[i], i); + } else { + --toResolve; + } + } + + } + + return d.promise; + + }); + } + + /** + * Traditional reduce function, similar to `Array.prototype.reduce()`, but + * input may contain {@link Promise}s and/or values, and reduceFunc + * may return either a value or a {@link Promise}, *and* initialValue may + * be a {@link Promise} for the starting value. + * @memberOf when + * + * @param promise {Array|Promise} array of anything, may contain a mix + * of {@link Promise}s and values. May also be a {@link Promise} for + * an array. + * @param reduceFunc {Function} reduce function reduce(currentValue, nextValue, index, total), + * where total is the total number of items being reduced, and will be the same + * in each call to reduceFunc. + * @param [initialValue] {*} starting value, or a {@link Promise} for the starting value + * @returns {Promise} that will resolve to the final reduced value + */ + function reduce(promise, reduceFunc /*, initialValue */) { + var args = slice.call(arguments, 1); + + return when(promise, function(array) { + var total; + + total = array.length; + + // Wrap the supplied reduceFunc with one that handles promises and then + // delegates to the supplied. + args[0] = function (current, val, i) { + return when(current, function (c) { + return when(val, function (value) { + return reduceFunc(c, value, i, total); + }); + }); + }; + + return reduceArray.apply(array, args); + }); + } + + /** + * Ensure that resolution of promiseOrValue will complete resolver with the completion + * value of promiseOrValue, or instead with resolveValue if it is provided. + * @memberOf when + * + * @param promiseOrValue + * @param resolver {Resolver} + * @param [resolveValue] anything + * @returns {Promise} + */ + function chain(promiseOrValue, resolver, resolveValue) { + var useResolveValue = arguments.length > 2; + + return when(promiseOrValue, + function(val) { + return resolver.resolve(useResolveValue ? resolveValue : val); + }, + resolver.reject, + resolver.progress + ); + } + + // + // Utility functions + // + + function processQueue(queue, value) { + var handler, i = 0; + + while (handler = queue[i++]) { + handler(value); + } + } + + /** + * Helper that checks arrayOfCallbacks to ensure that each element is either + * a function, or null or undefined. + * @private + * + * @param arrayOfCallbacks {Array} array to check + * @throws {Error} if any element of arrayOfCallbacks is something other than + * a Functions, null, or undefined. + */ + function checkCallbacks(start, arrayOfCallbacks) { + var arg, i = arrayOfCallbacks.length; + + while(i > start) { + arg = arrayOfCallbacks[--i]; + + if (arg != null && typeof arg != 'function') { + throw new Error('arg '+i+' must be a function'); + } + } + } + + /** + * No-Op function used in method replacement + * @private + */ + function noop() {} + + slice = [].slice; + + // ES5 reduce implementation if native not available + // See: http://es5.github.com/#x15.4.4.21 as there are many + // specifics and edge cases. + reduceArray = [].reduce || + function(reduceFunc /*, initialValue */) { + /*jshint maxcomplexity: 7*/ + + // ES5 dictates that reduce.length === 1 + + // This implementation deviates from ES5 spec in the following ways: + // 1. It does not check if reduceFunc is a Callable + + var arr, args, reduced, len, i; + + i = 0; + // This generates a jshint warning, despite being valid + // "Missing 'new' prefix when invoking a constructor." + // See https://github.com/jshint/jshint/issues/392 + arr = Object(this); + len = arr.length >>> 0; + args = arguments; + + // If no initialValue, use first item of array (we know length !== 0 here) + // and adjust i to start at second item + if(args.length <= 1) { + // Skip to the first real element in the array + for(;;) { + if(i in arr) { + reduced = arr[i++]; + break; + } + + // If we reached the end of the array without finding any real + // elements, it's a TypeError + if(++i >= len) { + throw new TypeError(); + } + } + } else { + // If initialValue provided, use it + reduced = args[1]; + } + + // Do the actual reduce + for(;i < len; ++i) { + // Skip holes + if(i in arr) { + reduced = reduceFunc(reduced, arr[i], i, arr); + } + } + + return reduced; + }; + + function identity(x) { + return x; + } + + return when; +}); +})(typeof define == 'function' && define.amd + ? define + : function (deps, factory) { typeof exports === 'object' + ? (module.exports = factory()) + : (this.when = factory()); + } + // Boilerplate for AMD, Node, and browser global +); + +/*global bane:false, when:false*/ + +function Mopidy(settings) { + this._settings = this._configure(settings || {}); + this._console = this._getConsole(); + + this._backoffDelay = this._settings.backoffDelayMin; + this._pendingRequests = {}; + this._webSocket = null; + + bane.createEventEmitter(this); + this._delegateEvents(); + + if (this._settings.autoConnect) { + this._connect(); + } +} + +Mopidy.prototype._configure = function (settings) { + settings.webSocketUrl = settings.webSocketUrl || + "ws://" + document.location.host + "/mopidy/ws/"; + + if (settings.autoConnect !== false) { + settings.autoConnect = true; + } + + settings.backoffDelayMin = settings.backoffDelayMin || 1000; + settings.backoffDelayMax = settings.backoffDelayMax || 64000; + + return settings; +}; + +Mopidy.prototype._getConsole = function () { + var console = window.console || {}; + + console.log = console.log || function () {}; + console.warn = console.warn || function () {}; + console.error = console.error || function () {}; + + return console; +}; + +Mopidy.prototype._delegateEvents = function () { + // Remove existing event handlers + this.off("websocket:close"); + this.off("websocket:error"); + this.off("websocket:incomingMessage"); + this.off("websocket:open"); + this.off("state:offline"); + + // Register basic set of event handlers + this.on("websocket:close", this._cleanup); + this.on("websocket:error", this._handleWebSocketError); + this.on("websocket:incomingMessage", this._handleMessage); + this.on("websocket:open", this._resetBackoffDelay); + this.on("websocket:open", this._getApiSpec); + this.on("state:offline", this._reconnect); +}; + +Mopidy.prototype._connect = function () { + if (this._webSocket) { + if (this._webSocket.readyState === WebSocket.OPEN) { + return; + } else { + this._webSocket.close(); + } + } + + this._webSocket = this._settings.webSocket || + new WebSocket(this._settings.webSocketUrl); + + this._webSocket.onclose = function (close) { + this.emit("websocket:close", close); + }.bind(this); + + this._webSocket.onerror = function (error) { + this.emit("websocket:error", error); + }.bind(this); + + this._webSocket.onopen = function () { + this.emit("websocket:open"); + }.bind(this); + + this._webSocket.onmessage = function (message) { + this.emit("websocket:incomingMessage", message); + }.bind(this); +}; + +Mopidy.prototype._cleanup = function (closeEvent) { + Object.keys(this._pendingRequests).forEach(function (requestId) { + var resolver = this._pendingRequests[requestId]; + delete this._pendingRequests[requestId]; + resolver.reject({ + message: "WebSocket closed", + closeEvent: closeEvent + }); + }.bind(this)); + + this.emit("state:offline"); +}; + +Mopidy.prototype._reconnect = function () { + this.emit("reconnectionPending", { + timeToAttempt: this._backoffDelay + }); + + setTimeout(function () { + this.emit("reconnecting"); + this._connect(); + }.bind(this), this._backoffDelay); + + this._backoffDelay = this._backoffDelay * 2; + if (this._backoffDelay > this._settings.backoffDelayMax) { + this._backoffDelay = this._settings.backoffDelayMax; + } +}; + +Mopidy.prototype._resetBackoffDelay = function () { + this._backoffDelay = this._settings.backoffDelayMin; +}; + +Mopidy.prototype._handleWebSocketError = function (error) { + this._console.warn("WebSocket error:", error.stack || error); +}; + +Mopidy.prototype._send = function (message) { + var deferred = when.defer(); + + switch (this._webSocket.readyState) { + case WebSocket.CONNECTING: + deferred.resolver.reject({ + message: "WebSocket is still connecting" + }); + break; + case WebSocket.CLOSING: + deferred.resolver.reject({ + message: "WebSocket is closing" + }); + break; + case WebSocket.CLOSED: + deferred.resolver.reject({ + message: "WebSocket is closed" + }); + break; + default: + message.jsonrpc = "2.0"; + message.id = this._nextRequestId(); + this._pendingRequests[message.id] = deferred.resolver; + this._webSocket.send(JSON.stringify(message)); + this.emit("websocket:outgoingMessage", message); + } + + return deferred.promise; +}; + +Mopidy.prototype._nextRequestId = (function () { + var lastUsed = -1; + return function () { + lastUsed += 1; + return lastUsed; + }; +}()); + +Mopidy.prototype._handleMessage = function (message) { + try { + var data = JSON.parse(message.data); + if (data.hasOwnProperty("id")) { + this._handleResponse(data); + } else if (data.hasOwnProperty("event")) { + this._handleEvent(data); + } else { + this._console.warn( + "Unknown message type received. Message was: " + + message.data); + } + } catch (error) { + if (error instanceof SyntaxError) { + this._console.warn( + "WebSocket message parsing failed. Message was: " + + message.data); + } else { + throw error; + } + } +}; + +Mopidy.prototype._handleResponse = function (responseMessage) { + if (!this._pendingRequests.hasOwnProperty(responseMessage.id)) { + this._console.warn( + "Unexpected response received. Message was:", responseMessage); + return; + } + + var resolver = this._pendingRequests[responseMessage.id]; + delete this._pendingRequests[responseMessage.id]; + + if (responseMessage.hasOwnProperty("result")) { + resolver.resolve(responseMessage.result); + } else if (responseMessage.hasOwnProperty("error")) { + resolver.reject(responseMessage.error); + this._console.warn("Server returned error:", responseMessage.error); + } else { + resolver.reject({ + message: "Response without 'result' or 'error' received", + data: {response: responseMessage} + }); + this._console.warn( + "Response without 'result' or 'error' received. Message was:", + responseMessage); + } +}; + +Mopidy.prototype._handleEvent = function (eventMessage) { + var type = eventMessage.event; + var data = eventMessage; + delete data.event; + + this.emit("event:" + this._snakeToCamel(type), data); +}; + +Mopidy.prototype._getApiSpec = function () { + this._send({method: "core.describe"}) + .then(this._createApi.bind(this), this._handleWebSocketError) + .then(null, this._handleWebSocketError); +}; + +Mopidy.prototype._createApi = function (methods) { + var caller = function (method) { + return function () { + var params = Array.prototype.slice.call(arguments); + return this._send({ + method: method, + params: params + }); + }.bind(this); + }.bind(this); + + var getPath = function (fullName) { + var path = fullName.split("."); + if (path.length >= 1 && path[0] === "core") { + path = path.slice(1); + } + return path; + }; + + var createObjects = function (objPath) { + var parentObj = this; + objPath.forEach(function (objName) { + objName = this._snakeToCamel(objName); + parentObj[objName] = parentObj[objName] || {}; + parentObj = parentObj[objName]; + }.bind(this)); + return parentObj; + }.bind(this); + + var createMethod = function (fullMethodName) { + var methodPath = getPath(fullMethodName); + var methodName = this._snakeToCamel(methodPath.slice(-1)[0]); + var object = createObjects(methodPath.slice(0, -1)); + object[methodName] = caller(fullMethodName); + object[methodName].description = methods[fullMethodName].description; + object[methodName].params = methods[fullMethodName].params; + }.bind(this); + + Object.keys(methods).forEach(createMethod); + this.emit("state:online"); +}; + +Mopidy.prototype._snakeToCamel = function (name) { + return name.replace(/(_[a-z])/g, function (match) { + return match.toUpperCase().replace("_", ""); + }); +}; diff --git a/mopidy/frontends/http/data/mopidy.min.js b/mopidy/frontends/http/data/mopidy.min.js new file mode 100644 index 00000000..dc9d63c5 --- /dev/null +++ b/mopidy/frontends/http/data/mopidy.min.js @@ -0,0 +1,5 @@ +/*! Mopidy.js - built 2012-12-03 + * http://www.mopidy.com/ + * Copyright (c) 2012 Stein Magnus Jodal and contributors + * Licensed under the Apache License, Version 2.0 */ +function Mopidy(e){this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this._connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file From dd9f0497ea3494bc58dea817837bd577a86843c1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 00:06:44 +0100 Subject: [PATCH 109/144] js: Document Mopidy.js usage --- mopidy/frontends/http/__init__.py | 297 +++++++++++++++++++++++++++++- 1 file changed, 292 insertions(+), 5 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index d98734b2..ebc57345 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -115,17 +115,304 @@ Example JSON-RPC request:: Example JSON-RPC response:: - {"jsonrpc": "2.0", "id": 1, "result": {"__model__": "Track", ...}} + {"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. +Mopidy.js JavaScript library +============================ + +We've made a JavaScript library, Mopidy.js, which wraps the WebSocket and gets +you quickly started with working on your client instead of figuring out how to +communicate with Mopidy. + + +Getting the library +------------------- + +Regular and minified versions of Mopidy.js, ready for use, is installed +together with Mopidy. When the HTTP frontend is running, the files are +available at: + +- http://localhost:6680/mopidy/mopidy.js +- http://localhost:6680/mopidy/mopidy.min.js + +You may need to adjust hostname and port for your local setup. + +Thus, if you use Mopidy to host your web client, like described above, you can +load the latest version of Mopidy.js by adding the following script tag to your +HTML file: + +.. code-block:: html + + + +If you don't use Mopidy to host your web client, you can find the JS files in +the Git repo at: + +- ``mopidy/frontends/http/data/mopidy.js`` +- ``mopidy/frontends/http/data/mopidy.min.js`` + +If you want to work on the Mopidy.js library itself, you'll find a complete +development setup in the ``js/`` dir in our repo. The instructions in +``js/README.rst`` will guide you on your way. + + +Creating an instance +-------------------- + +Once you got Mopidy.js loaded, you need to create an instance of the wrapper: + +.. code-block:: js + + var mopidy = new Mopidy(); + +When you instantiate ``Mopidy()`` without arguments, it will connect to +the WebSocket at ``/mopidy/ws/`` on the current host. Thus, if you don't host +your web client using Mopidy's web server, you'll need to pass the URL to the +WebSocket end point: + +.. code-block:: js + + var mopidy = new Mopidy({ + webSocketUrl: "ws://localhost:6680/mopidy/ws/" + }); + + +Hooking up to events +-------------------- + +Once you have a Mopidy.js object, you can hook up to the events it emits. To +explore your possibilities, it can be useful to subscribe to all events and log +them: + +.. code-block:: js + + mopidy.on(console.log); + +Several types of events are emitted: + +- You can get notified about when the Mopidy.js object is connected to the + server and ready for method calls, when it's offline, and when it's trying to + reconnect to the server by looking at the events ``state:online``, + ``state:offline``, ``reconnectionPending``, and ``reconnecting``. + +- You can get events sent from the Mopidy server by looking at the events with + the name prefix ``event:``, like ``event:trackPlaybackStarted``. + +- You can introspect what happens internally on the WebSocket by looking at the + events emitted with the name prefix ``websocket:``. + +Mopidy.js uses the event emitter library `BANE +`_, so you should refer to BANE's +short API documentation to see how you can hook up your listeners to the +different events. + + +Calling core API methods +------------------------ + +Once your Mopidy.js object has connected to the Mopidy server and emits the +``state:online`` event, it is ready to accept core API method calls: + +.. code-block:: js + + mopidy.on("state:online", function () [ + mopidy.playback.next(); + }); + +All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core +API attributes is *not* available, but that shouldn't be a problem as we've +added (undocumented) getters and setters for all of them, so you can access the +attributes as well from JavaScript. + +Both the WebSocket API and the JavaScript API are based on introspection of the +core Python API. Thus, they will always be up to date and immediately reflect +any changes we do to the core API. + +The best way to explore the JavaScript API, is probably by opening your +browser's console, and using its tab completion to navigate the API. You'll +find the Mopidy core API exposed under ``mopidy.playback``, +``mopidy.tracklist``, ``mopidy.playlists``, and ``mopidy.library``. + +All methods in the JavaScript API have an associated data structure describing +the params it expects, and most methods also got API documentation available. +This is available right there in the browser console, by looking at the +method's ``description`` and ``params`` attributes: + +.. code-block:: js + + console.log(mopidy.playback.next.params); + console.log(mopidy.playback.next.description); + +Obviously, you'll want to get a return value from many of your method calls. +Since everything is happening across the WebSocket and maybe even across the +network, you'll get the results asynchronously. Instead of having to pass +callbacks and errbacks to every method you call, the methods return "promise" +objects, which you can use to pipe the future result as input to another +method, or to hook up callback and errback functions. + +.. code-block:: js + + var track = mopidy.playback.getCurrentTrack(); + // => ``track`` isn't a track, but a "promise" object + +Instead, typical usage will look like this: + +.. code-block:: js + + var printCurrentTrack = function (track) { + if (track) { + console.log("Currently playing:", track.name, "by", + track.artists[0].name, "from", track.album.name); + } else { + console.log("No current track"); + } + }; + + mopidy.playback.getCurrentTrack().then(printCurrentTrack, console.error); + +The first function passed to ``then()``, ``printCurrentTrack``, is the callback +that will be called if the method call succeeds. The second function, +``console.error``, is the errback that will be called if anything goes wrong. +If you don't hook up an errback, debugging will be hard as errors will silently +go missing. + +For debugging, you may be interested in errors from function without +interesting return values as well. In that case, you can pass ``null`` as the +callback: + +.. code-block:: js + + mopidy.playback.next().then(null, console.error); + +The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A +`_ standard. We use the +implementation known as `when.js `_. Please +refer to when.js' documentation or the standard for further details on how to +work with promise objects. + + +Example to get started with +--------------------------- + +1. Create an empty directory for your web client. + +2. Change the setting :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` to point + to your new directory. + +3. Make sure that you've included + ``mopidy.frontends.http.HttpFrontend`` in + :attr:`mopidy.settings.FRONTENDS`. + +4. Start/restart Mopidy. + +5. Create a file in the directory named ``index.html`` containing e.g. "Hello, + world!". + +6. Visit http://localhost:6680/ to confirm that you can view your new HTML file + there. + +7. Include Mopidy.js in your web page: + + .. code-block:: html + + + +8. Add one of the following Mopidy.js examples of how to queue and start + playback of your first playlist either to your web page or a JavaScript file + that you include in your web page. + + "Imperative" style: + + .. code-block:: js + + var trackDesc = function (track) { + return track.name + " by " + track.artists[0].name + + " from " + track.album.name; + }; + + var queueAndPlayFirstPlaylist = function () { + mopidy.playlists.getPlaylists().then(function (playlists) { + var playlist = playlists[0]; + console.log("Loading playlist:", playlist.name); + mopidy.tracklist.add(playlist.tracks).then(function (tlTracks) { + mopidy.playback.play(tlTracks[0]).then(function () { + mopidy.playback.getCurrentTrack().then(function (track) { + console.log("Now playing:", trackDesc(track)); + }, console.error); + }, console.error); + }, console.error); + }, console.error); + }; + + var mopidy = new Mopidy(); // Connect to server + mopidy.on(console.log); // Log all events + mopidy.on("state:online", queueAndPlayFirstPlaylist); + + Approximately the same behavior in a more functional style, using chaining + of promisies. + + .. code-block:: js + + var getFirst = function (list) { + return list[0]; + }; + + var extractTracks = function (playlist) { + return playlist.tracks; + }; + + var printTypeAndName = function (model) { + console.log(model.__model__ + ": " + model.name); + // By returning the playlist, this function can be inserted + // anywhere a model with a name is piped in the chain. + return model; + }; + + var trackDesc = function (track) { + return track.name + " by " + track.artists[0].name + + " from " + track.album.name; + }; + + var printNowPlaying = function () { + // By returning any arguments we get, the function can be inserted + // anywhere in the chain. + var args = arguments; + return mopidy.playback.getCurrentTrack().then(function (track) { + console.log("Now playing:", trackDesc(track)); + return args; + }); + }; + + var queueAndPlayFirstPlaylist = function () { + mopidy.playlists.getPlaylists() + // => list of Playlists + .then(getFirst, console.error) + // => Playlist + .then(printTypeAndName, console.error) + // => Playlist + .then(extractTracks, console.error) + // => list of Tracks + .then(mopidy.tracklist.add, console.error) + // => list of TlTracks + .then(getFirst, console.error) + // => TlTrack + .then(mopidy.playback.play, console.error) + // => null + .then(printNowPlaying, console.error); + }; + + var mopidy = new Mopidy(); // Connect to server + mopidy.on(console.log); // Log all events + mopidy.on("state:online", queueAndPlayFirstPlaylist); + +9. The web page should now queue and play your first playlist every time your + load it. See the browser's console for output from the function, any errors, + and a all events that are emitted. """ # flake8: noqa From 006e411cd89a4f56f5d6b431d87eaa782d5a06ff Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 00:28:54 +0100 Subject: [PATCH 110/144] docs: Update changelog --- docs/changes.rst | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index a8a4fb0e..3bb4ca9f 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -7,6 +7,24 @@ This change log is used to track all major changes to Mopidy. v0.10.0 (in development) ======================== +**Documentation** + +- Added installation instructions for Fedora. + +**Spotify backend** + +- Save a lot of memory by reusing artist, album, and track models. + +- Make sure the playlist loading hack only runs once. + +**Local backend** + +- Change log level from error to warning on messages emitted when the tag cache + isn't found and a couple of similar cases. + +- Make ``mopidy-scan`` ignore invalid dates, e.g. dates in years outside the + range 1-9999. + **HTTP frontend** - Added new optional HTTP frontend which exposes Mopidy's core API through @@ -19,9 +37,6 @@ v0.10.0 (in development) ``glib``. The bug can be worked around by overriding the settings that includes offending `$XDG_` variables. -- Make ``mopidy-scan`` ignore invalid dates, e.g. dates in years outside the - range 1-9999. - v0.9.0 (2012-11-21) =================== From 510b821db85ad97835df94f4b73ccbc2adc307b1 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 00:30:26 +0100 Subject: [PATCH 111/144] docs: Formatting --- docs/changes.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index 3bb4ca9f..29b11490 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -35,7 +35,7 @@ v0.10.0 (in development) - :issue:`256`: Fix crash caused by non-ASCII characters in paths returned from ``glib``. The bug can be worked around by overriding the settings that - includes offending `$XDG_` variables. + includes offending ``$XDG_`` variables. v0.9.0 (2012-11-21) From b5c5bcd645353cf91dfa2a9e547ed96ca9eef10f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 22:59:01 +0100 Subject: [PATCH 112/144] js: Fix grammatical error --- mopidy/frontends/http/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index ebc57345..4fa48c79 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -239,7 +239,7 @@ find the Mopidy core API exposed under ``mopidy.playback``, ``mopidy.tracklist``, ``mopidy.playlists``, and ``mopidy.library``. All methods in the JavaScript API have an associated data structure describing -the params it expects, and most methods also got API documentation available. +the params it expects, and most methods also have API documentation available. This is available right there in the browser console, by looking at the method's ``description`` and ``params`` attributes: From d860ab0ae040d541b732430f79b3a72379d0a41a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 23:02:24 +0100 Subject: [PATCH 113/144] js: Document behavior on calls when we're offline --- mopidy/frontends/http/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 4fa48c79..7f8db3b2 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -224,6 +224,10 @@ Once your Mopidy.js object has connected to the Mopidy server and emits the mopidy.playback.next(); }); +Any calls you make before the ``state:online`` event is emitted will fail. If +you've hooked up an errback (more on that a bit later) to the promise returned +from the call, the errback will be called with an error message. + All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core API attributes is *not* available, but that shouldn't be a problem as we've added (undocumented) getters and setters for all of them, so you can access the From ddd3d6ba98630a7bc3dca852cf12b89b24d80f49 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 23:05:25 +0100 Subject: [PATCH 114/144] js: 'params' and 'description' is from the Python API --- mopidy/frontends/http/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 7f8db3b2..fab5ff80 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -243,9 +243,9 @@ find the Mopidy core API exposed under ``mopidy.playback``, ``mopidy.tracklist``, ``mopidy.playlists``, and ``mopidy.library``. All methods in the JavaScript API have an associated data structure describing -the params it expects, and most methods also have API documentation available. -This is available right there in the browser console, by looking at the -method's ``description`` and ``params`` attributes: +the Python params it expects, and most methods also have the Python API +documentation available. This is available right there in the browser console, +by looking at the method's ``description`` and ``params`` attributes: .. code-block:: js From 126b7815d042ee1cc2997cbdd83445d4891ea16a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 23:08:40 +0100 Subject: [PATCH 115/144] js: Document parameter handling --- mopidy/frontends/http/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index fab5ff80..7697b60e 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -252,6 +252,11 @@ by looking at the method's ``description`` and ``params`` attributes: console.log(mopidy.playback.next.params); console.log(mopidy.playback.next.description); +JSON-RPC 2.0 limits method parameters to be sent *either* by-position or +by-name. Combinations of both, like we're used to from Python, isn't supported +by JSON-RPC 2.0. To further limit this, Mopidy.js currently only supports +passing parameters by-position. + Obviously, you'll want to get a return value from many of your method calls. Since everything is happening across the WebSocket and maybe even across the network, you'll get the results asynchronously. Instead of having to pass From b807c38b01172793d4c460074933d64573de4c79 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 4 Dec 2012 09:04:16 +0100 Subject: [PATCH 116/144] js: Upgrade BANE to 0.4 --- js/lib/{bane-0.3.0.js => bane-0.4.0.js} | 42 ++++++++++++++++------ mopidy/frontends/http/data/mopidy.js | 44 ++++++++++++++++++------ mopidy/frontends/http/data/mopidy.min.js | 4 +-- 3 files changed, 67 insertions(+), 23 deletions(-) rename js/lib/{bane-0.3.0.js => bane-0.4.0.js} (79%) diff --git a/js/lib/bane-0.3.0.js b/js/lib/bane-0.4.0.js similarity index 79% rename from js/lib/bane-0.3.0.js rename to js/lib/bane-0.4.0.js index b82b8cd9..a1da6efa 100644 --- a/js/lib/bane-0.3.0.js +++ b/js/lib/bane-0.4.0.js @@ -3,7 +3,7 @@ * * https://github.com/busterjs/bane * - * @version 0.3.0 + * @version 0.4.0 */ ((typeof define === "function" && define.amd && function (m) { define(m); }) || @@ -43,6 +43,11 @@ return event ? object.listeners[event] : object.listeners; } + function errbacks(object) { + if (!object.errbacks) { object.errbacks = []; } + return object.errbacks; + } + /** * @signature var emitter = bane.createEmitter([object]); * @@ -56,7 +61,7 @@ try { listener.listener.apply(listener.thisp || object, args); } catch (e) { - handleError(event, e, object.errbacks || []); + handleError(event, e, errbacks(object)); } } @@ -74,7 +79,24 @@ }; object.off = function (event, listener) { - var fns, i, l; + var fns, events, i, l; + if (!event) { + fns = supervisors(this); + fns.splice(0, fns.length); + + events = listeners(this); + for (i in events) { + if (events.hasOwnProperty(i)) { + fns = listeners(this, i); + fns.splice(0, fns.length); + } + } + + fns = errbacks(this); + fns.splice(0, fns.length); + + return; + } if (typeof event === "function") { fns = supervisors(this); listener = event; @@ -82,8 +104,8 @@ fns = listeners(this, event); } if (!listener) { - fns.splice(0, fns.length); - return; + fns.splice(0, fns.length); + return; } for (i = 0, l = fns.length; i < l; ++i) { if (fns[i].listener === listener) { @@ -123,15 +145,15 @@ }; object.emit = function (event) { - var toNotify = listeners(this, event).slice(); - var args = slice.call(arguments, 1), i, l; + var toNotify = supervisors(this); + var args = slice.call(arguments), i, l; - for (i = 0, l = toNotify.length; i < l; i++) { + for (i = 0, l = toNotify.length; i < l; ++i) { notifyListener(event, toNotify[i], args); } - toNotify = supervisors(this); - args = slice.call(arguments); + toNotify = listeners(this, event).slice() + args = slice.call(arguments, 1); for (i = 0, l = toNotify.length; i < l; ++i) { notifyListener(event, toNotify[i], args); } diff --git a/mopidy/frontends/http/data/mopidy.js b/mopidy/frontends/http/data/mopidy.js index 29f076c5..55fec91c 100644 --- a/mopidy/frontends/http/data/mopidy.js +++ b/mopidy/frontends/http/data/mopidy.js @@ -1,4 +1,4 @@ -/*! Mopidy.js - built 2012-12-03 +/*! Mopidy.js - built 2012-12-04 * http://www.mopidy.com/ * Copyright (c) 2012 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ @@ -8,7 +8,7 @@ * * https://github.com/busterjs/bane * - * @version 0.3.0 + * @version 0.4.0 */ ((typeof define === "function" && define.amd && function (m) { define(m); }) || @@ -48,6 +48,11 @@ return event ? object.listeners[event] : object.listeners; } + function errbacks(object) { + if (!object.errbacks) { object.errbacks = []; } + return object.errbacks; + } + /** * @signature var emitter = bane.createEmitter([object]); * @@ -61,7 +66,7 @@ try { listener.listener.apply(listener.thisp || object, args); } catch (e) { - handleError(event, e, object.errbacks || []); + handleError(event, e, errbacks(object)); } } @@ -79,7 +84,24 @@ }; object.off = function (event, listener) { - var fns, i, l; + var fns, events, i, l; + if (!event) { + fns = supervisors(this); + fns.splice(0, fns.length); + + events = listeners(this); + for (i in events) { + if (events.hasOwnProperty(i)) { + fns = listeners(this, i); + fns.splice(0, fns.length); + } + } + + fns = errbacks(this); + fns.splice(0, fns.length); + + return; + } if (typeof event === "function") { fns = supervisors(this); listener = event; @@ -87,8 +109,8 @@ fns = listeners(this, event); } if (!listener) { - fns.splice(0, fns.length); - return; + fns.splice(0, fns.length); + return; } for (i = 0, l = fns.length; i < l; ++i) { if (fns[i].listener === listener) { @@ -128,15 +150,15 @@ }; object.emit = function (event) { - var toNotify = listeners(this, event).slice(); - var args = slice.call(arguments, 1), i, l; + var toNotify = supervisors(this); + var args = slice.call(arguments), i, l; - for (i = 0, l = toNotify.length; i < l; i++) { + for (i = 0, l = toNotify.length; i < l; ++i) { notifyListener(event, toNotify[i], args); } - toNotify = supervisors(this); - args = slice.call(arguments); + toNotify = listeners(this, event).slice() + args = slice.call(arguments, 1); for (i = 0, l = toNotify.length; i < l; ++i) { notifyListener(event, toNotify[i], args); } diff --git a/mopidy/frontends/http/data/mopidy.min.js b/mopidy/frontends/http/data/mopidy.min.js index dc9d63c5..9c7b9e7c 100644 --- a/mopidy/frontends/http/data/mopidy.min.js +++ b/mopidy/frontends/http/data/mopidy.min.js @@ -1,5 +1,5 @@ -/*! Mopidy.js - built 2012-12-03 +/*! Mopidy.js - built 2012-12-04 * http://www.mopidy.com/ * Copyright (c) 2012 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ -function Mopidy(e){this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this._connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file +function Mopidy(e){this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this._connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file From 43316babcd35d7ef08892dd552052c8ef6514325 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 4 Dec 2012 09:05:52 +0100 Subject: [PATCH 117/144] js: Remove trailing whitespace --- js/test/mopidy-test.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index 8525e09b..c134c0e1 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -156,7 +156,7 @@ buster.testCase("Mopidy", { "is called when the state changes to offline": function () { var stub = this.stub(this.mopidy, "_reconnect"); this.mopidy._delegateEvents(); - + this.mopidy.emit("state:offline"); assert.calledOnceWith(stub); @@ -334,13 +334,13 @@ buster.testCase("Mopidy", { this.mopidy._webSocket.readyState = WebSocket.CONNECTING; var promise = this.mopidy._send({method: "foo"}); - + refute.called(this.mopidy._webSocket.send); promise.then(done(function () { assert(false); }), done(function (error) { assert.equals( - error.message, "WebSocket is still connecting"); + error.message, "WebSocket is still connecting"); })); }, @@ -348,13 +348,13 @@ buster.testCase("Mopidy", { this.mopidy._webSocket.readyState = WebSocket.CLOSING; var promise = this.mopidy._send({method: "foo"}); - + refute.called(this.mopidy._webSocket.send); promise.then(done(function () { assert(false); }), done(function (error) { assert.equals( - error.message, "WebSocket is closing"); + error.message, "WebSocket is closing"); })); }, @@ -362,13 +362,13 @@ buster.testCase("Mopidy", { this.mopidy._webSocket.readyState = WebSocket.CLOSED; var promise = this.mopidy._send({method: "foo"}); - + refute.called(this.mopidy._webSocket.send); promise.then(done(function () { assert(false); }), done(function (error) { assert.equals( - error.message, "WebSocket is closed"); + error.message, "WebSocket is closed"); })); } }, @@ -401,7 +401,7 @@ buster.testCase("Mopidy", { result: null }; var messageEvent = {data: JSON.stringify(message)}; - + this.mopidy._handleMessage(messageEvent); assert.calledOnceWith(stub, message); @@ -414,7 +414,7 @@ buster.testCase("Mopidy", { track: {} }; var messageEvent = {data: JSON.stringify(message)}; - + this.mopidy._handleMessage(messageEvent); assert.calledOnceWith(stub, message); From 8d3fd468086e64c73467e950fb8c55b42e6d3652 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 4 Dec 2012 09:18:34 +0100 Subject: [PATCH 118/144] js: Add close() method to close WebSocket without reconnecting --- js/src/mopidy.js | 5 +++++ js/test/mopidy-test.js | 17 +++++++++++++++++ mopidy/frontends/http/data/mopidy.js | 5 +++++ mopidy/frontends/http/data/mopidy.min.js | 2 +- 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 26ac9197..66c17b79 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -119,6 +119,11 @@ Mopidy.prototype._resetBackoffDelay = function () { this._backoffDelay = this._settings.backoffDelayMin; }; +Mopidy.prototype.close = function () { + this.off("state:offline", this._reconnect); + this._webSocket.close(); +}; + Mopidy.prototype._handleWebSocketError = function (error) { this._console.warn("WebSocket error:", error.stack || error); }; diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index c134c0e1..fd1f73c6 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -254,6 +254,23 @@ buster.testCase("Mopidy", { } }, + "close": { + "unregisters reconnection hooks": function () { + this.stub(this.mopidy, "off"); + + this.mopidy.close(); + + assert.calledOnceWith( + this.mopidy.off, "state:offline", this.mopidy._reconnect); + }, + + "closes the WebSocket": function () { + this.mopidy.close(); + + assert.calledOnceWith(this.mopidy._webSocket.close); + } + }, + "._handleWebSocketError": { "is called on 'websocket:error' event": function () { var error = {}; diff --git a/mopidy/frontends/http/data/mopidy.js b/mopidy/frontends/http/data/mopidy.js index 55fec91c..6249ef7f 100644 --- a/mopidy/frontends/http/data/mopidy.js +++ b/mopidy/frontends/http/data/mopidy.js @@ -1028,6 +1028,11 @@ Mopidy.prototype._resetBackoffDelay = function () { this._backoffDelay = this._settings.backoffDelayMin; }; +Mopidy.prototype.close = function () { + this.off("state:offline", this._reconnect); + this._webSocket.close(); +}; + Mopidy.prototype._handleWebSocketError = function (error) { this._console.warn("WebSocket error:", error.stack || error); }; diff --git a/mopidy/frontends/http/data/mopidy.min.js b/mopidy/frontends/http/data/mopidy.min.js index 9c7b9e7c..42d34319 100644 --- a/mopidy/frontends/http/data/mopidy.min.js +++ b/mopidy/frontends/http/data/mopidy.min.js @@ -2,4 +2,4 @@ * http://www.mopidy.com/ * Copyright (c) 2012 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ -function Mopidy(e){this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this._connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file +function Mopidy(e){this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this._connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file From 68a6f2f8aaeaf680e5731514dd231c4b4183a382 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 4 Dec 2012 09:23:32 +0100 Subject: [PATCH 119/144] js: Doc how to clean up after Mopidy.js --- mopidy/frontends/http/__init__.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 7697b60e..d81d4791 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -305,6 +305,28 @@ refer to when.js' documentation or the standard for further details on how to work with promise objects. +Cleaning up +----------- + +If you for some reason want to clean up after Mopidy.js before the web page is +closed or navigated away from, you can close the WebSocket, unregister all +event listeners, and delete the object like this: + +.. code-block:: js + + // Close the WebSocket without reconnecting. Letting the object be garbage + // collected will have the same effect, so this isn't striclty necessary. + mopidy.close(); + + // Unregister all event listeners. If you don't do this, you may have + // lingering references to the object causing the garbage collector to not + // clean up after it. + mopidy.off(); + + // Delete your reference to the object, so it can be garbage collected. + mopidy = null; + + Example to get started with --------------------------- From 74cbb91cfbc2022c60057f79b5a0ca1e1adf08d3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 4 Dec 2012 13:02:14 +0100 Subject: [PATCH 120/144] docs: Add Mopidy.js to changelog --- docs/changes.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 29b11490..59ce272d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -31,6 +31,9 @@ v0.10.0 (in development) JSON-RPC 2.0 messages over a WebSocket. See :ref:`http-frontend` for further details. +- Added a JavaScript library, Mopidy.js, to make it easier to develop web based + Mopidy clients using the new HTTP frontend. + **Bug fixes** - :issue:`256`: Fix crash caused by non-ASCII characters in paths returned from From b14c89dbf1f7537dd9623c195c87d74a934a5837 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 4 Dec 2012 13:23:43 +0100 Subject: [PATCH 121/144] js: Make connect() public, and document autoConnect/connect() --- js/src/mopidy.js | 6 +++--- js/test/mopidy-test.js | 18 ++++++++++++++---- mopidy/frontends/http/__init__.py | 9 +++++++++ mopidy/frontends/http/data/mopidy.js | 6 +++--- mopidy/frontends/http/data/mopidy.min.js | 2 +- 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/js/src/mopidy.js b/js/src/mopidy.js index 66c17b79..3485b442 100644 --- a/js/src/mopidy.js +++ b/js/src/mopidy.js @@ -12,7 +12,7 @@ function Mopidy(settings) { this._delegateEvents(); if (this._settings.autoConnect) { - this._connect(); + this.connect(); } } @@ -57,7 +57,7 @@ Mopidy.prototype._delegateEvents = function () { this.on("state:offline", this._reconnect); }; -Mopidy.prototype._connect = function () { +Mopidy.prototype.connect = function () { if (this._webSocket) { if (this._webSocket.readyState === WebSocket.OPEN) { return; @@ -106,7 +106,7 @@ Mopidy.prototype._reconnect = function () { setTimeout(function () { this.emit("reconnecting"); - this._connect(); + this.connect(); }.bind(this), this._backoffDelay); this._backoffDelay = this._backoffDelay * 2; diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js index fd1f73c6..7dcc5972 100644 --- a/js/test/mopidy-test.js +++ b/js/test/mopidy-test.js @@ -51,12 +51,22 @@ buster.testCase("Mopidy", { } }, - "._connect": { + ".connect": { + "connects when autoConnect is false": function () { + var mopidy = new Mopidy({autoConnect: false}); + refute.called(this.webSocketConstructorStub); + + mopidy.connect(); + + assert.calledOnceWith(this.webSocketConstructorStub, + "ws://" + document.location.host + "/mopidy/ws/"); + }, + "does nothing when the WebSocket is open": function () { this.webSocket.readyState = WebSocket.OPEN; var mopidy = new Mopidy({webSocket: this.webSocket}); - mopidy._connect(); + mopidy.connect(); refute.called(this.webSocket.close); refute.called(this.webSocketConstructorStub); @@ -164,7 +174,7 @@ buster.testCase("Mopidy", { "tries to connect after an increasing backoff delay": function () { var clock = this.useFakeTimers(); - var connectStub = this.stub(this.mopidy, "_connect"); + var connectStub = this.stub(this.mopidy, "connect"); var pendingSpy = this.spy(); this.mopidy.on("reconnectionPending", pendingSpy); var reconnectingSpy = this.spy(); @@ -209,7 +219,7 @@ buster.testCase("Mopidy", { "tries to connect at least about once per minute": function () { var clock = this.useFakeTimers(); - var connectStub = this.stub(this.mopidy, "_connect"); + var connectStub = this.stub(this.mopidy, "connect"); var pendingSpy = this.spy(); this.mopidy.on("reconnectionPending", pendingSpy); this.mopidy._backoffDelay = this.mopidy._settings.backoffDelayMax; diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index d81d4791..59c867d8 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -181,6 +181,15 @@ WebSocket end point: webSocketUrl: "ws://localhost:6680/mopidy/ws/" }); +It is also possible to create an instance first and connect to the WebSocket +later: + +.. code-block:: js + + var mopidy = new Mopidy({autoConnect: false}); + // ... do other stuff, like hooking up events ... + mopidy.connect(); + Hooking up to events -------------------- diff --git a/mopidy/frontends/http/data/mopidy.js b/mopidy/frontends/http/data/mopidy.js index 6249ef7f..5b022c0c 100644 --- a/mopidy/frontends/http/data/mopidy.js +++ b/mopidy/frontends/http/data/mopidy.js @@ -921,7 +921,7 @@ function Mopidy(settings) { this._delegateEvents(); if (this._settings.autoConnect) { - this._connect(); + this.connect(); } } @@ -966,7 +966,7 @@ Mopidy.prototype._delegateEvents = function () { this.on("state:offline", this._reconnect); }; -Mopidy.prototype._connect = function () { +Mopidy.prototype.connect = function () { if (this._webSocket) { if (this._webSocket.readyState === WebSocket.OPEN) { return; @@ -1015,7 +1015,7 @@ Mopidy.prototype._reconnect = function () { setTimeout(function () { this.emit("reconnecting"); - this._connect(); + this.connect(); }.bind(this), this._backoffDelay); this._backoffDelay = this._backoffDelay * 2; diff --git a/mopidy/frontends/http/data/mopidy.min.js b/mopidy/frontends/http/data/mopidy.min.js index 42d34319..797b47ec 100644 --- a/mopidy/frontends/http/data/mopidy.min.js +++ b/mopidy/frontends/http/data/mopidy.min.js @@ -2,4 +2,4 @@ * http://www.mopidy.com/ * Copyright (c) 2012 Stein Magnus Jodal and contributors * Licensed under the Apache License, Version 2.0 */ -function Mopidy(e){this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this._connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file +function Mopidy(e){this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this.connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})}; \ No newline at end of file From 4e4df2adf2553ae6e28eb69af2d2b8f67ad2f615 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 4 Dec 2012 13:23:57 +0100 Subject: [PATCH 122/144] js: Make library inclusion order stable --- js/grunt.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/js/grunt.js b/js/grunt.js index 7be4d882..d835fd77 100644 --- a/js/grunt.js +++ b/js/grunt.js @@ -23,7 +23,12 @@ module.exports = function (grunt) { }, concat: { dist: { - src: ["", "lib/**/*.js", "src/mopidy.js"], + src: [ + "", + "lib/bane-*.js", + "lib/when-*.js", + "src/mopidy.js" + ], dest: "<%= dirs.dest %>/mopidy.js" } }, From 30a5fd06c24298811f09ed6a3f2f086f732c8b25 Mon Sep 17 00:00:00 2001 From: Matt Bray Date: Sun, 9 Dec 2012 23:52:20 +0000 Subject: [PATCH 123/144] Fix for updates to pyspotify Pyspotify now creates the session in `pyspotify.SpotifySessionManager.__init__` (rather than in `.connect`) - see [here][1]. Therefore it seems best not to set `self.session = None` in `mopidy.SpotifySessionManager.__init__` or `self.session = session` in `logged_in`. [1]: https://github.com/mopidy/pyspotify/commit/483f7574303dc7afed491244fc382ce49add64c4#L1R39 --- mopidy/backends/spotify/session_manager.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 2336ad4d..8112f885 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -46,7 +46,6 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.backend_ref = backend_ref self.connected = threading.Event() - self.session = None self.container_manager = None self.playlist_manager = None @@ -64,7 +63,6 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): return logger.info('Connected to Spotify') - self.session = session logger.debug( 'Preferred Spotify bitrate is %s kbps', From beac2e80ed3c8f862461af1d14bf7ac3aef4e686 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 7 Dec 2012 12:04:39 +0100 Subject: [PATCH 124/144] mpd: Use file:// URIs in tag_cache file:// URIs are uriencoded, and will thus conserve the encoding of the file paths. We cannot just convert file paths in other encodings to UTF-8, because then we won't find the files we point to. --- mopidy/frontends/mpd/translator.py | 6 ++---- tests/frontends/mpd/serializer_test.py | 19 ++++++++----------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 36b00772..3b77f929 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -154,7 +154,6 @@ def tracks_to_tag_cache_format(tracks): def _add_to_tag_cache(result, folders, files): music_folder = settings.LOCAL_MUSIC_PATH - regexp = '^' + re.escape(music_folder).rstrip('/') + '/?' for path, entry in folders.items(): name = os.path.split(path)[1] @@ -168,9 +167,8 @@ def _add_to_tag_cache(result, folders, files): result.append(('songList begin',)) for track in files: track_result = dict(track_to_mpd_format(track)) - path = uri_to_path(track_result['file']) - track_result['mtime'] = get_mtime(path) - track_result['file'] = re.sub(regexp, '', path) + track_result['mtime'] = get_mtime(uri_to_path(track_result['file'])) + track_result['file'] = track_result['file'] track_result['key'] = os.path.basename(track_result['file']) track_result = order_mpd_track_info(track_result.items()) result.extend(track_result) diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py index 711a069e..211db600 100644 --- a/tests/frontends/mpd/serializer_test.py +++ b/tests/frontends/mpd/serializer_test.py @@ -4,7 +4,7 @@ import datetime import os from mopidy import settings -from mopidy.utils.path import mtime, uri_to_path +from mopidy.utils.path import mtime from mopidy.frontends.mpd import translator, protocol from mopidy.models import Album, Artist, TlTrack, Playlist, Track @@ -131,10 +131,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): mtime.undo_fake() def translate(self, track): - folder = settings.LOCAL_MUSIC_PATH result = dict(translator.track_to_mpd_format(track)) - result['file'] = uri_to_path(result['file']) - result['file'] = result['file'][len(folder) + 1:] result['key'] = os.path.basename(result['file']) result['mtime'] = mtime('') return translator.order_mpd_track_info(result.items()) @@ -197,7 +194,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): result = self.consume_headers(result) song_list, result = self.consume_song_list(result) - self.assertEqual(song_list, formated) + self.assertEqual(formated, song_list) self.assertEqual(len(result), 0) def test_tag_cache_has_formated_track_with_key_and_mtime(self): @@ -208,7 +205,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): result = self.consume_headers(result) song_list, result = self.consume_song_list(result) - self.assertEqual(song_list, formated) + self.assertEqual(formated, song_list) self.assertEqual(len(result), 0) def test_tag_cache_suports_directories(self): @@ -224,7 +221,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): song_list, result = self.consume_song_list(folder) self.assertEqual(len(result), 0) - self.assertEqual(song_list, formated) + self.assertEqual(formated, song_list) def test_tag_cache_diretory_header_is_right(self): track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') @@ -256,7 +253,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): song_list, result = self.consume_song_list(folder) self.assertEqual(len(result), 0) - self.assertEqual(song_list, formated) + self.assertEqual(formated, song_list) def test_tag_cache_supports_multiple_tracks(self): tracks = [ @@ -273,7 +270,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): result = self.consume_headers(result) song_list, result = self.consume_song_list(result) - self.assertEqual(song_list, formated) + self.assertEqual(formated, song_list) self.assertEqual(len(result), 0) def test_tag_cache_supports_multiple_tracks_in_dirs(self): @@ -292,12 +289,12 @@ class TracksToTagCacheFormatTest(unittest.TestCase): folder, result = self.consume_directory(result) song_list, song_result = self.consume_song_list(folder) - self.assertEqual(song_list, formated[1]) + self.assertEqual(formated[1], song_list) self.assertEqual(len(song_result), 0) song_list, result = self.consume_song_list(result) self.assertEqual(len(result), 0) - self.assertEqual(song_list, formated[0]) + self.assertEqual(formated[0], song_list) class TracksToDirectoryTreeTest(unittest.TestCase): From cdda3bc5dd7786842a712f78da9710e1c88999d5 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 11 Dec 2012 11:00:28 +0100 Subject: [PATCH 125/144] scanner: Add -q and -v options for controlling log output --- docs/changes.rst | 4 ++++ mopidy/scanner.py | 53 +++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 59ce272d..bdbd7a3b 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -25,6 +25,10 @@ v0.10.0 (in development) - Make ``mopidy-scan`` ignore invalid dates, e.g. dates in years outside the range 1-9999. +- Make ``mopidy-scan`` accept :opt:`-q`/:opt:`--quiet` and + :opt:`-v`/:opt:`--verbose` options to control the amount of logging output + when scanning. + **HTTP frontend** - Added new optional HTTP frontend which exposes Mopidy's core API through diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 829b501a..e66f27c4 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -1,11 +1,35 @@ from __future__ import unicode_literals -import logging import datetime +import logging +import optparse +import os +import sys import gobject gobject.threads_init() + +# Extract any non-GStreamer arguments, and leave the GStreamer arguments for +# processing by GStreamer. This needs to be done before GStreamer is imported, +# so that GStreamer doesn't hijack e.g. ``--help``. +# NOTE This naive fix does not support values like ``bar`` in +# ``--gst-foo bar``. Use equals to pass values, like ``--gst-foo=bar``. + +def is_gst_arg(argument): + return argument.startswith('--gst') or argument == '--help-gst' + +gstreamer_args = [arg for arg in sys.argv[1:] if is_gst_arg(arg)] +mopidy_args = [arg for arg in sys.argv[1:] if not is_gst_arg(arg)] +sys.argv[1:] = gstreamer_args + + +# Add ../ to the path so we can run Mopidy from a Git checkout without +# installing it on the system. +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) + + import pygst pygst.require('0.10') import gst @@ -13,12 +37,14 @@ import gst from mopidy import settings from mopidy.frontends.mpd import translator as mpd_translator from mopidy.models import Track, Artist, Album -from mopidy.utils import log, path +from mopidy.utils import log, path, versioning def main(): + options = parse_options() + log.setup_root_logger() - log.setup_console_logging(2) + log.setup_console_logging(options.verbosity_level) tracks = [] @@ -28,7 +54,8 @@ def main(): logging.debug('Added %s', track.uri) def debug(uri, error, debug): - logging.error('Failed %s: %s - %s', uri, error, debug) + logging.warning('Failed %s: %s', uri, error) + logging.debug('Debug info for %s: %s', uri, debug) logging.info('Scanning %s', settings.LOCAL_MUSIC_PATH) scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug) @@ -46,6 +73,20 @@ def main(): print ('%s: %s' % row).encode('utf-8') +def parse_options(): + parser = optparse.OptionParser( + version='Mopidy %s' % versioning.get_version()) + parser.add_option( + '-q', '--quiet', + action='store_const', const=0, dest='verbosity_level', + help='less output (warning level)') + parser.add_option( + '-v', '--verbose', + action='count', default=1, dest='verbosity_level', + help='more output (debug level)') + return parser.parse_args(args=mopidy_args)[0] + + def translator(data): albumartist_kwargs = {} album_kwargs = {} @@ -192,3 +233,7 @@ class Scanner(object): def stop(self): self.pipe.set_state(gst.STATE_NULL) self.loop.quit() + + +if __name__ == '__main__': + main() From 40aa6db3c4e861db3d4bf758fd5842718f7c529a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 11 Dec 2012 11:31:32 +0100 Subject: [PATCH 126/144] docs: Fix Sphinx syntax --- docs/changes.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index bdbd7a3b..aa69536c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -25,9 +25,9 @@ v0.10.0 (in development) - Make ``mopidy-scan`` ignore invalid dates, e.g. dates in years outside the range 1-9999. -- Make ``mopidy-scan`` accept :opt:`-q`/:opt:`--quiet` and - :opt:`-v`/:opt:`--verbose` options to control the amount of logging output - when scanning. +- Make ``mopidy-scan`` accept :option:`-q`/:option:`--quiet` and + :option:`-v`/:option:`--verbose` options to control the amount of logging + output when scanning. **HTTP frontend** From 3d86610dfe142e0c286b8fda2a69c400d013b3e6 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 11 Dec 2012 12:09:55 +0100 Subject: [PATCH 127/144] scanner: Better info logging --- mopidy/scanner.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index e66f27c4..0b10d061 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -58,13 +58,14 @@ def main(): logging.debug('Debug info for %s: %s', uri, debug) logging.info('Scanning %s', settings.LOCAL_MUSIC_PATH) + scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug) try: scanner.start() except KeyboardInterrupt: scanner.stop() - logging.info('Done') + logging.info('Done scanning; writing tag cache...') for row in mpd_translator.tracks_to_tag_cache_format(tracks): if len(row) == 1: @@ -72,6 +73,8 @@ def main(): else: print ('%s: %s' % row).encode('utf-8') + logging.info('Done writing tag cache') + def parse_options(): parser = optparse.OptionParser( From f302949ea96fd73a33417a01686a5301db06b014 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 13:43:41 +0100 Subject: [PATCH 128/144] tests: Use empty tag cache in all local backend tests --- tests/backends/local/events_test.py | 11 ++++++++++- tests/backends/local/playback_test.py | 1 + tests/backends/local/playlists_test.py | 8 ++++++++ tests/backends/local/tracklist_test.py | 3 ++- tests/backends/local/translator_test.py | 2 +- 5 files changed, 22 insertions(+), 3 deletions(-) diff --git a/tests/backends/local/events_test.py b/tests/backends/local/events_test.py index ba61f97a..79d2780b 100644 --- a/tests/backends/local/events_test.py +++ b/tests/backends/local/events_test.py @@ -1,8 +1,17 @@ +from mopidy import settings from mopidy.backends.local import LocalBackend -from tests import unittest +from tests import unittest, path_to_data_dir from tests.backends.base import events class LocalBackendEventsTest(events.BackendEventsTest, unittest.TestCase): backend_class = LocalBackend + + def setUp(self): + settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') + super(LocalBackendEventsTest, self).setUp() + + def tearDown(self): + super(LocalBackendEventsTest, self).tearDown() + settings.runtime.clear() diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 9731f70d..9a306ee0 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -18,6 +18,7 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) + settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') super(LocalPlaybackControllerTest, self).setUp() def tearDown(self): diff --git a/tests/backends/local/playlists_test.py b/tests/backends/local/playlists_test.py index fcc39132..70ed27d6 100644 --- a/tests/backends/local/playlists_test.py +++ b/tests/backends/local/playlists_test.py @@ -18,6 +18,14 @@ class LocalPlaylistsControllerTest( backend_class = LocalBackend + def setUp(self): + settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') + super(LocalPlaylistsControllerTest, self).setUp() + + def tearDown(self): + super(LocalPlaylistsControllerTest, self).tearDown() + settings.runtime.clear() + def test_created_playlist_is_persisted(self): path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') self.assertFalse(os.path.exists(path)) diff --git a/tests/backends/local/tracklist_test.py b/tests/backends/local/tracklist_test.py index f5330f52..735043d6 100644 --- a/tests/backends/local/tracklist_test.py +++ b/tests/backends/local/tracklist_test.py @@ -4,7 +4,7 @@ from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.models import Track -from tests import unittest +from tests import unittest, path_to_data_dir from tests.backends.base.tracklist import TracklistControllerTest from tests.backends.local import generate_song @@ -16,6 +16,7 @@ class LocalTracklistControllerTest(TracklistControllerTest, unittest.TestCase): def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) + settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('empty_tag_cache') super(LocalTracklistControllerTest, self).setUp() def tearDown(self): diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index e18b13fe..90ee849d 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -5,9 +5,9 @@ from __future__ import unicode_literals import os import tempfile -from mopidy.utils.path import path_to_uri from mopidy.backends.local.translator import parse_m3u, parse_mpd_tag_cache from mopidy.models import Track, Artist, Album +from mopidy.utils.path import path_to_uri from tests import unittest, path_to_data_dir From b5d9dc10a70a660184757760fb55223ef2d164ae Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 15:03:46 +0100 Subject: [PATCH 129/144] utils: Handle paths with non-UTF-8 encodings - path_to_uri() encodes unicode input as UTF-8 and leaves bytestring input unchanged before it is converted to file:// URIs. - uri_to_path() will now always return bytestrings, since we don't know if there is any non-UTF-8 encoded chars in the file path, and converting it to unicode would make such paths no longer match the dir or file it was referring to. - split_path() will now assume it gets a bytestring in. --- mopidy/utils/path.py | 31 ++++++++++++++++++++++++++----- tests/utils/path_test.py | 36 ++++++++++++++++++++++++++++++------ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 73063183..eea13fb1 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -51,19 +51,40 @@ def get_or_create_file(filename): def path_to_uri(*paths): + """ + Convert OS specific path to file:// URI. + + Accepts either unicode strings or bytestrings. The encoding of any + bytestring will be maintained so that :func:`uri_to_path` can return the + same bytestring. + + Returns a file:// URI as an unicode string. + """ path = os.path.join(*paths) - path = path.encode('utf-8') + if isinstance(path, unicode): + path = path.encode('utf-8') if sys.platform == 'win32': return 'file:' + urllib.pathname2url(path) return 'file://' + urllib.pathname2url(path) def uri_to_path(uri): + """ + Convert the file:// to a OS specific path. + + Returns a bytestring, since the file path can contain chars with other + encoding than UTF-8. + + If we had returned these paths as unicode strings, you wouldn't be able to + look up the matching dir or file on your file system because the exact path + would be lost by ignoring its encoding. + """ + if isinstance(uri, unicode): + uri = uri.encode('utf-8') if sys.platform == 'win32': - path = urllib.url2pathname(re.sub('^file:', '', uri)) + return urllib.url2pathname(re.sub(b'^file:', b'', uri)) else: - path = urllib.url2pathname(re.sub('^file://', '', uri)) - return path.encode('latin1').decode('utf-8') # Undo double encoding + return urllib.url2pathname(re.sub(b'^file://', b'', uri)) def split_path(path): @@ -72,7 +93,7 @@ def split_path(path): path, part = os.path.split(path) if part: parts.insert(0, part) - if not path or path == '/': + if not path or path == b'/': break return parts diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 512a3ba1..cfe58e0a 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -90,31 +90,55 @@ class PathToFileURITest(unittest.TestCase): result = path.path_to_uri('/tmp/æøå') self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') + def test_utf8_in_path(self): + if sys.platform == 'win32': + result = path.path_to_uri('C:/æøå'.encode('utf-8')) + self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5') + else: + result = path.path_to_uri('/tmp/æøå'.encode('utf-8')) + self.assertEqual(result, 'file:///tmp/%C3%A6%C3%B8%C3%A5') + + def test_latin1_in_path(self): + if sys.platform == 'win32': + result = path.path_to_uri('C:/æøå'.encode('latin-1')) + self.assertEqual(result, 'file:///C://%E6%F8%E5') + else: + result = path.path_to_uri('/tmp/æøå'.encode('latin-1')) + self.assertEqual(result, 'file:///tmp/%E6%F8%E5') + class UriToPathTest(unittest.TestCase): def test_simple_uri(self): if sys.platform == 'win32': result = path.uri_to_path('file:///C://WINDOWS/clock.avi') - self.assertEqual(result, 'C:/WINDOWS/clock.avi') + self.assertEqual(result, 'C:/WINDOWS/clock.avi'.encode('utf-8')) else: result = path.uri_to_path('file:///etc/fstab') - self.assertEqual(result, '/etc/fstab') + self.assertEqual(result, '/etc/fstab'.encode('utf-8')) def test_space_in_uri(self): if sys.platform == 'win32': result = path.uri_to_path('file:///C://test%20this') - self.assertEqual(result, 'C:/test this') + self.assertEqual(result, 'C:/test this'.encode('utf-8')) else: result = path.uri_to_path('file:///tmp/test%20this') - self.assertEqual(result, '/tmp/test this') + self.assertEqual(result, '/tmp/test this'.encode('utf-8')) def test_unicode_in_uri(self): if sys.platform == 'win32': result = path.uri_to_path('file:///C://%C3%A6%C3%B8%C3%A5') - self.assertEqual(result, 'C:/æøå') + self.assertEqual(result, 'C:/æøå'.encode('utf-8')) else: result = path.uri_to_path('file:///tmp/%C3%A6%C3%B8%C3%A5') - self.assertEqual(result, '/tmp/æøå') + self.assertEqual(result, '/tmp/æøå'.encode('utf-8')) + + def test_latin1_in_uri(self): + if sys.platform == 'win32': + result = path.uri_to_path('file:///C://%E6%F8%E5') + self.assertEqual(result, 'C:/æøå'.encode('latin-1')) + else: + result = path.uri_to_path('file:///tmp/%E6%F8%E5') + self.assertEqual(result, '/tmp/æøå'.encode('latin-1')) class SplitPathTest(unittest.TestCase): From 905ceeb72a8ee5bb0aa8911f7a6fa06a40458417 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Mon, 3 Dec 2012 15:05:18 +0100 Subject: [PATCH 130/144] utils: find_files() returns bytestrings --- mopidy/utils/path.py | 30 ++++++++++++++++-------------- tests/utils/path_test.py | 6 +++--- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index eea13fb1..8ef5e187 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -106,30 +106,32 @@ def expand_path(path): def find_files(path): + """ + Finds all files within a path. + + Directories and files with names starting with ``.`` is ignored. + + :returns: yields the full path to files as bytestrings + """ + if isinstance(path, unicode): + path = path.encode('utf-8') + if os.path.isfile(path): - if not isinstance(path, unicode): - path = path.decode('utf-8') - if not os.path.basename(path).startswith('.'): + if not os.path.basename(path).startswith(b'.'): yield path else: for dirpath, dirnames, filenames in os.walk(path): - # Filter out hidden folders by modifying dirnames in place. for dirname in dirnames: - if dirname.startswith('.'): + if dirname.startswith(b'.'): + # Skip hidden folders by modifying dirnames inplace dirnames.remove(dirname) for filename in filenames: - # Skip hidden files. - if filename.startswith('.'): + if filename.startswith(b'.'): + # Skip hidden files continue - filename = os.path.join(dirpath, filename) - if not isinstance(filename, unicode): - try: - filename = filename.decode('utf-8') - except UnicodeDecodeError: - filename = filename.decode('latin1') - yield filename + yield os.path.join(dirpath, filename) def check_file_path_is_inside_base_dir(file_path, base_path): diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index cfe58e0a..629819f8 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -201,11 +201,11 @@ class FindFilesTest(unittest.TestCase): self.assertEqual(len(files), 1) self.assert_(files[0], path_to_data_dir('blank.mp3')) - def test_names_are_unicode(self): - is_unicode = lambda f: isinstance(f, unicode) + def test_names_are_bytestrings(self): + is_bytes = lambda f: isinstance(f, bytes) for name in self.find(''): self.assert_( - is_unicode(name), '%s is not unicode object' % repr(name)) + is_bytes(name), '%s is not unicode object' % repr(name)) def test_ignores_hidden_folders(self): self.assertEqual(self.find('.hidden'), []) From a006918453b9b7e2cf89635967687ff8730a037b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Fri, 7 Dec 2012 12:11:48 +0100 Subject: [PATCH 131/144] mpd: Use bytestrings in dir tree building --- mopidy/frontends/mpd/translator.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 3b77f929..b16264a1 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -177,16 +177,17 @@ def _add_to_tag_cache(result, folders, files): def tracks_to_directory_tree(tracks): directories = ({}, []) + for track in tracks: - path = '' + path = b'' current = directories - local_folder = settings.LOCAL_MUSIC_PATH - track_path = uri_to_path(track.uri) - track_path = re.sub('^' + re.escape(local_folder), '', track_path) - track_dir = os.path.dirname(track_path) + absolute_track_dir_path = os.path.dirname(uri_to_path(track.uri)) + relative_track_dir_path = re.sub( + '^' + re.escape(settings.LOCAL_MUSIC_PATH), b'', + absolute_track_dir_path) - for part in split_path(track_dir): + for part in split_path(relative_track_dir_path): path = os.path.join(path, part) if path not in current[0]: current[0][path] = ({}, []) From f5dc5aba83bf4b8a1b86117359a9e10e271f30ac Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 14:03:43 +0100 Subject: [PATCH 132/144] docs: Add HTTP clients page --- docs/clients/http.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 docs/clients/http.rst diff --git a/docs/clients/http.rst b/docs/clients/http.rst new file mode 100644 index 00000000..e41adb5b --- /dev/null +++ b/docs/clients/http.rst @@ -0,0 +1,14 @@ +.. _http-clients: + +************ +HTTP clients +************ + +Mopidy added an :ref:`http-frontend` in 0.10 which provides the building blocks +needed for creating web clients for Mopidy with the help of a WebSocket and a +JavaScript library provided by Mopidy. + +This page will list any HTTP/web Mopidy clients. If you've created one, please +notify us so we can include your client on this page. + +See :ref:`http-frontend` for details on how to build your own web client. From 6311e13cecc1c1cd8868d0e53890b2b42f7149d8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 11 Dec 2012 12:03:26 +0100 Subject: [PATCH 133/144] mpd: urlencode any non-UTF-8 path so it can be displayed as UTF-8 --- mopidy/frontends/mpd/translator.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index b16264a1..1d7b52aa 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import os import re +import urllib from mopidy import settings from mopidy.frontends.mpd import protocol @@ -153,13 +154,16 @@ def tracks_to_tag_cache_format(tracks): def _add_to_tag_cache(result, folders, files): - music_folder = settings.LOCAL_MUSIC_PATH + base_path = settings.LOCAL_MUSIC_PATH.encode('utf-8') for path, entry in folders.items(): - name = os.path.split(path)[1] - mtime = get_mtime(os.path.join(music_folder, path)) - result.append(('directory', path)) - result.append(('mtime', mtime)) + try: + text_path = path.decode('utf-8') + except UnicodeDecodeError: + text_path = urllib.pathname2url(path).decode('utf-8') + name = os.path.split(text_path)[1] + result.append(('directory', text_path)) + result.append(('mtime', get_mtime(os.path.join(base_path, path)))) result.append(('begin', name)) _add_to_tag_cache(result, *entry) result.append(('end', name)) @@ -167,9 +171,13 @@ def _add_to_tag_cache(result, folders, files): result.append(('songList begin',)) for track in files: track_result = dict(track_to_mpd_format(track)) - track_result['mtime'] = get_mtime(uri_to_path(track_result['file'])) - track_result['file'] = track_result['file'] - track_result['key'] = os.path.basename(track_result['file']) + path = uri_to_path(track_result['file']) + try: + text_path = path.decode('utf-8') + except UnicodeDecodeError: + text_path = urllib.pathname2url(path).decode('utf-8') + track_result['mtime'] = get_mtime(path) + track_result['key'] = os.path.basename(text_path) track_result = order_mpd_track_info(track_result.items()) result.extend(track_result) result.append(('songList end',)) From e9eac16284153b44eed556dba9753f5789ddf99a Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 15:07:00 +0100 Subject: [PATCH 134/144] mpd: Use relative urlencoded paths in tag cache This partly reverts "beac2e8 mpd: Use file:// URIs in tag_cache" by removing the "file://" URI scheme and the music dir base path from the "file:" fields in the tag cache. The advantage is that the tag cache becomes independent of the music dir location and the tag cache loader can be made compatible with both old and new tag caches. --- mopidy/frontends/mpd/translator.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 1d7b52aa..b2113dda 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -169,17 +169,25 @@ def _add_to_tag_cache(result, folders, files): result.append(('end', name)) result.append(('songList begin',)) + for track in files: track_result = dict(track_to_mpd_format(track)) + path = uri_to_path(track_result['file']) try: text_path = path.decode('utf-8') except UnicodeDecodeError: text_path = urllib.pathname2url(path).decode('utf-8') + relative_path = os.path.relpath(path, base_path) + relative_uri = urllib.pathname2url(relative_path) + + track_result['file'] = relative_uri track_result['mtime'] = get_mtime(path) track_result['key'] = os.path.basename(text_path) track_result = order_mpd_track_info(track_result.items()) + result.extend(track_result) + result.append(('songList end',)) From c8a068b02ca6665e4bc335fb808071894e4c3ef4 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 15:08:51 +0100 Subject: [PATCH 135/144] local: Support tag caches with urlencoded paths This adds support for loading tag caches where the "file:" field has urlencoded paths. For old tag caches without the urlencoding, this is a noop. Thus, old tag caches continues to work. --- mopidy/backends/local/translator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 59e2957a..5f2a9bc5 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals import logging +import urllib from mopidy.models import Track, Artist, Album from mopidy.utils.encoding import locale_decode @@ -139,6 +140,7 @@ def _convert_mpd_data(data, tracks, music_dir): path = data['file'][1:] else: path = data['file'] + path = urllib.uri2pathname(path) if artist_kwargs: artist = Artist(**artist_kwargs) From b397162bd0020332c45d9ab1fa0a3555066cdb20 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 14:45:36 +0100 Subject: [PATCH 136/144] docs: Update changelog --- docs/changes.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index aa69536c..acade010 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -29,6 +29,10 @@ v0.10.0 (in development) :option:`-v`/:option:`--verbose` options to control the amount of logging output when scanning. +- The scanner can now handle files with other encodings than UTF-8. Rebuild + your tag cache with ``mopidy-scan`` to include tracks that may have been + ignored previously. + **HTTP frontend** - Added new optional HTTP frontend which exposes Mopidy's core API through From 1707d6ae6e708b47586bfb5416f649e052c9c823 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 15:43:16 +0100 Subject: [PATCH 137/144] local: Fix typo --- mopidy/backends/local/translator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 5f2a9bc5..00500ef7 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -140,7 +140,7 @@ def _convert_mpd_data(data, tracks, music_dir): path = data['file'][1:] else: path = data['file'] - path = urllib.uri2pathname(path) + path = urllib.url2pathname(path) if artist_kwargs: artist = Artist(**artist_kwargs) From b76e27a62bda316c5a673845929efec04b26cfe8 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 15:54:04 +0100 Subject: [PATCH 138/144] mpd: Revert full URI in tag cache test as well --- tests/frontends/mpd/serializer_test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py index 211db600..aa3b77bb 100644 --- a/tests/frontends/mpd/serializer_test.py +++ b/tests/frontends/mpd/serializer_test.py @@ -4,7 +4,7 @@ import datetime import os from mopidy import settings -from mopidy.utils.path import mtime +from mopidy.utils.path import mtime, uri_to_path from mopidy.frontends.mpd import translator, protocol from mopidy.models import Album, Artist, TlTrack, Playlist, Track @@ -131,7 +131,9 @@ class TracksToTagCacheFormatTest(unittest.TestCase): mtime.undo_fake() def translate(self, track): + base_path = settings.LOCAL_MUSIC_PATH.encode('utf-8') result = dict(translator.track_to_mpd_format(track)) + result['file'] = uri_to_path(result['file'])[len(base_path) + 1:] result['key'] = os.path.basename(result['file']) result['mtime'] = mtime('') return translator.order_mpd_track_info(result.items()) From ac4411ec83d95abc307489a8e61f7e0e905b4800 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 16:08:16 +0100 Subject: [PATCH 139/144] spotify: Make session manager work with pyspotify 1.9 _and_ 1.10 --- mopidy/backends/spotify/session_manager.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 8112f885..288c61f2 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -64,15 +64,19 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): logger.info('Connected to Spotify') + # To work with both pyspotify 1.9 and 1.10 + if not hasattr(self, 'session'): + self.session = session + logger.debug( 'Preferred Spotify bitrate is %s kbps', settings.SPOTIFY_BITRATE) - self.session.set_preferred_bitrate(BITRATES[settings.SPOTIFY_BITRATE]) + session.set_preferred_bitrate(BITRATES[settings.SPOTIFY_BITRATE]) self.container_manager = SpotifyContainerManager(self) self.playlist_manager = SpotifyPlaylistManager(self) - self.container_manager.watch(self.session.playlist_container()) + self.container_manager.watch(session.playlist_container()) self.connected.set() @@ -177,5 +181,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): def logout(self): """Log out from spotify""" logger.debug('Logging out from Spotify') - if self.session: + + # To work with both pyspotify 1.9 and 1.10 + if getattr(self, 'session', None): self.session.logout() From a221036e5a4f831690fe56fe71d9f506d3489c4e Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 23:05:12 +0100 Subject: [PATCH 140/144] tests: Fix error message --- tests/utils/path_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 629819f8..461f0809 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -205,7 +205,7 @@ class FindFilesTest(unittest.TestCase): is_bytes = lambda f: isinstance(f, bytes) for name in self.find(''): self.assert_( - is_bytes(name), '%s is not unicode object' % repr(name)) + is_bytes(name), '%s is not bytes object' % repr(name)) def test_ignores_hidden_folders(self): self.assertEqual(self.find('.hidden'), []) From 0f603c3eded58cef063f1f033f3c7a29a0c8e7b3 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 12 Dec 2012 23:13:52 +0100 Subject: [PATCH 141/144] Use urllib.{quote,unquote} instead of {pathname2url,url2pathname} --- mopidy/backends/local/translator.py | 2 +- mopidy/frontends/mpd/translator.py | 6 +++--- mopidy/utils/path.py | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 00500ef7..ff58a16e 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -140,7 +140,7 @@ def _convert_mpd_data(data, tracks, music_dir): path = data['file'][1:] else: path = data['file'] - path = urllib.url2pathname(path) + path = urllib.unquote(path) if artist_kwargs: artist = Artist(**artist_kwargs) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index b2113dda..0c95f044 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -160,7 +160,7 @@ def _add_to_tag_cache(result, folders, files): try: text_path = path.decode('utf-8') except UnicodeDecodeError: - text_path = urllib.pathname2url(path).decode('utf-8') + text_path = urllib.quote(path).decode('utf-8') name = os.path.split(text_path)[1] result.append(('directory', text_path)) result.append(('mtime', get_mtime(os.path.join(base_path, path)))) @@ -177,9 +177,9 @@ def _add_to_tag_cache(result, folders, files): try: text_path = path.decode('utf-8') except UnicodeDecodeError: - text_path = urllib.pathname2url(path).decode('utf-8') + text_path = urllib.quote(path).decode('utf-8') relative_path = os.path.relpath(path, base_path) - relative_uri = urllib.pathname2url(relative_path) + relative_uri = urllib.quote(relative_path) track_result['file'] = relative_uri track_result['mtime'] = get_mtime(path) diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 8ef5e187..c4fa0ce2 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -64,8 +64,8 @@ def path_to_uri(*paths): if isinstance(path, unicode): path = path.encode('utf-8') if sys.platform == 'win32': - return 'file:' + urllib.pathname2url(path) - return 'file://' + urllib.pathname2url(path) + return 'file:' + urllib.quote(path) + return 'file://' + urllib.quote(path) def uri_to_path(uri): @@ -82,9 +82,9 @@ def uri_to_path(uri): if isinstance(uri, unicode): uri = uri.encode('utf-8') if sys.platform == 'win32': - return urllib.url2pathname(re.sub(b'^file:', b'', uri)) + return urllib.unquote(re.sub(b'^file:', b'', uri)) else: - return urllib.url2pathname(re.sub(b'^file://', b'', uri)) + return urllib.unquote(re.sub(b'^file://', b'', uri)) def split_path(path): From 2b54837c64e8e7818ca10e29e473e221fc5f65d9 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 00:16:33 +0100 Subject: [PATCH 142/144] Bump version number to 0.10.0 --- mopidy/__init__.py | 2 +- tests/version_test.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 918e1459..049db682 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -23,7 +23,7 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.9.0' +__version__ = '0.10.0' from mopidy import settings as default_settings_module diff --git a/tests/version_test.py b/tests/version_test.py index 966b8b94..271f004a 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -31,5 +31,6 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.7.2'), SV('0.7.3')) self.assertLess(SV('0.7.3'), SV('0.8.0')) self.assertLess(SV('0.8.0'), SV('0.8.1')) - self.assertLess(SV('0.8.1'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.9.1')) + self.assertLess(SV('0.8.1'), SV('0.9.0')) + self.assertLess(SV('0.9.0'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.10.1')) From 24ace415a00458200020c78d6b230ef6b2b15417 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 00:19:43 +0100 Subject: [PATCH 143/144] spotify: pyspotify 1.9 and 1.10 are both supported --- docs/changes.rst | 5 +++++ mopidy/backends/spotify/__init__.py | 2 +- requirements/spotify.txt | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index acade010..d3c32d3f 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -7,6 +7,11 @@ This change log is used to track all major changes to Mopidy. v0.10.0 (in development) ======================== +**Dependencies** + +- pyspotify >= 1.9, < 1.11 is now required for Spotify support. In other words, + you're free to upgrade to pyspotify 1.10, but it isn't a requirement. + **Documentation** - Added installation instructions for Fedora. diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 141656cc..a8e9ffda 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -21,7 +21,7 @@ https://github.com/mopidy/mopidy/issues?labels=Spotify+backend **Dependencies:** - libspotify >= 12, < 13 (libspotify12 package from apt.mopidy.com) -- pyspotify >= 1.9, < 1.10 (python-spotify package from apt.mopidy.com) +- pyspotify >= 1.9, < 1.11 (python-spotify package from apt.mopidy.com) **Settings:** diff --git a/requirements/spotify.txt b/requirements/spotify.txt index c37d4674..b501e63e 100644 --- a/requirements/spotify.txt +++ b/requirements/spotify.txt @@ -1 +1 @@ -pyspotify >= 1.9, < 1.10 +pyspotify >= 1.9, < 1.11 From 005b751efb62b584906f25e06e484217c9e6fe90 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 13 Dec 2012 00:20:03 +0100 Subject: [PATCH 144/144] docs: Update changelog for v0.10.0 --- docs/changes.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index d3c32d3f..b6e433d3 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,8 +4,10 @@ Changes This change log is used to track all major changes to Mopidy. -v0.10.0 (in development) -======================== +v0.10.0 (2012-12-12) +==================== + +We've added an HTTP frontend for those wanting to build web clients for Mopidy! **Dependencies**