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/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/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 = {} diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index cfe4e433..2336ad4d 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -142,8 +142,9 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): # startup until the Spotify backend is ready from 35s to 12s in one # test with clean Spotify cache. In cases with an outdated cache # the time improvements should be a lot greater. - self._initial_data_receive_completed = True - self.refresh_playlists() + if not self._initial_data_receive_completed: + self._initial_data_receive_completed = True + self.refresh_playlists() def end_of_track(self, session): """Callback used by pyspotify""" diff --git a/mopidy/core/listener.py b/mopidy/core/listener.py index dc8bf1d7..7c4ab093 100644 --- a/mopidy/core/listener.py +++ b/mopidy/core/listener.py @@ -19,7 +19,20 @@ class CoreListener(object): """Helper to allow calling of core listener events""" listeners = pykka.ActorRegistry.get_by_class(CoreListener) for listener in listeners: - getattr(listener.proxy(), event)(**kwargs) + listener.proxy().on_event(event, **kwargs) + + def on_event(self, event, **kwargs): + """ + Called on all events. + + *MAY* be implemented by actor. By default, this method forwards the + event to the specific event methods. + + :param event: the event name + :type event: string + :param kwargs: any other arguments to the specific event handlers + """ + getattr(self, event)(**kwargs) def track_playback_paused(self, track, time_position): """ diff --git a/mopidy/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 diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index fd1d2b01..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,36 +16,72 @@ 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`. -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 -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. + As a simple security measure, the web server is by default only available + from localhost. To make it available from other computers, change + :attr:`mopidy.settings.HTTP_SERVER_HOSTNAME`. Before you do so, note that + the HTTP frontend does not feature any form of user authentication or + authorization. Anyone able to access the web server can use the full core + API of Mopidy. Thus, you probably only want to make the web server + available from your local network or place it behind a web proxy which + takes care or user authentication. You have been warned. + + +Using a web based Mopidy client +=============================== The web server can also host any static files, for example the HTML, CSS, -JavaScript and images needed 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 + + Since this frontend exposes our internal core API directly it is to be + regarded as **experimental**. We cannot promise to keep any form of + backwards compatibility between releases as we will need to change the core + API while working out how to support new use cases. Thus, if you use this + API, you must expect to do small adjustments to your client for every + release of Mopidy. + + From Mopidy 1.0 and onwards, we intend to keep the core API far more + stable. + +The web server exposes a WebSocket at ``/mopidy/ws/``. The WebSocket gives you +access to Mopidy's full API and enables Mopidy to instantly push events to the +client, as they happen. On the WebSocket we send two different kind of messages: The client can send JSON-RPC 2.0 requests, and the server will respond with JSON-RPC 2.0 responses. In addition, the server will send event messages when something happens on the server. Both message types are encoded as JSON objects. + +Event messages +-------------- + Event objects will always have a key named ``event`` whose value is the event type. Depending on the event type, the event may include additional fields for related data. The events maps directly to the :class:`mopidy.core.CoreListener` @@ -54,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 @@ -66,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:: @@ -80,20 +121,11 @@ 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. - -**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 diff --git a/mopidy/frontends/http/actor.py b/mopidy/frontends/http/actor.py index 65cf9445..8ad0f026 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', @@ -93,42 +98,8 @@ 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): - event = {} - event.update(data) + def on_event(self, name, **data): + event = data event['event'] = name message = json.dumps(event, cls=models.ModelJSONEncoder) cherrypy.engine.publish('websocket-broadcast', TextMessage(message)) diff --git a/mopidy/frontends/http/data/favicon.png b/mopidy/frontends/http/data/favicon.png new file mode 100644 index 00000000..a214c91f Binary files /dev/null and b/mopidy/frontends/http/data/favicon.png differ diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 3c177036..c83780fb 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -35,6 +35,8 @@ def format_dependency_list(adapters=None): pylast_info, dbus_info, serial_info, + cherrypy_info, + ws4py_info, ] lines = [] @@ -189,3 +191,25 @@ 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 + + +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/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() 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() diff --git a/tests/core/listener_test.py b/tests/core/listener_test.py index 2e121796..8aaf1234 100644 --- a/tests/core/listener_test.py +++ b/tests/core/listener_test.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import mock + from mopidy.core import CoreListener, PlaybackState from mopidy.models import Playlist, Track @@ -10,6 +12,15 @@ class CoreListenerTest(unittest.TestCase): def setUp(self): self.listener = CoreListener() + def test_on_event_forwards_to_specific_handler(self): + self.listener.track_playback_paused = mock.Mock() + + self.listener.on_event( + 'track_playback_paused', track=Track(), position=0) + + self.listener.track_playback_paused.assert_called_with( + track=Track(), position=0) + def test_listener_has_default_impl_for_track_playback_paused(self): self.listener.track_playback_paused(Track(), 0) diff --git a/tests/frontends/http/events_test.py b/tests/frontends/http/events_test.py index 9df4a2b5..631802c4 100644 --- a/tests/frontends/http/events_test.py +++ b/tests/frontends/http/events_test.py @@ -1,21 +1,29 @@ 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()) 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 +33,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', - }) diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index 168f98e5..65a1eda1 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -27,6 +27,16 @@ try: except ImportError: spotify = False +try: + import cherrypy +except ImportError: + cherrypy = False + +try: + import ws4py +except ImportError: + ws4py = False + from mopidy.utils import deps from tests import unittest @@ -115,3 +125,19 @@ 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']) + + @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']) diff --git a/tests/utils/jsonrpc_test.py b/tests/utils/jsonrpc_test.py index 64b5e628..7c8a0a9b 100644 --- a/tests/utils/jsonrpc_test.py +++ b/tests/utils/jsonrpc_test.py @@ -260,7 +260,7 @@ class JsonRpcBatchTest(JsonRpcTestBase): self.assertEqual(len(response), 3) - response = {row['id']: row for row in response} + response = dict((row['id'], row) for row in response) self.assertEqual(response[1]['result'], False) self.assertEqual(response[2]['result'], True) self.assertEqual(response[3]['result'], False) @@ -277,7 +277,7 @@ class JsonRpcBatchTest(JsonRpcTestBase): self.assertEqual(len(response), 2) - response = {row['id']: row for row in response} + response = dict((row['id'], row) for row in response) self.assertNotIn(1, response) self.assertEqual(response[2]['result'], True) self.assertEqual(response[3]['result'], False) @@ -313,7 +313,7 @@ class JsonRpcSingleCommandErrorTest(JsonRpcTestBase): data = error['data'] self.assertEqual(data['type'], 'ValueError') - self.assertEqual(data['message'], "u'bogus' is not in list") + self.assertIn('not in list', data['message']) self.assertIn('traceback', data) self.assertIn('Traceback (most recent call last):', data['traceback']) @@ -522,7 +522,7 @@ class JsonRpcBatchErrorTest(JsonRpcTestBase): response = self.jrw.handle_data(request) self.assertEqual(len(response), 5) - response = {row['id']: row for row in response} + response = dict((row['id'], row) for row in response) self.assertEqual(response['1']['result'], None) self.assertEqual(response['2']['result'], None) self.assertEqual(response[None]['error']['code'], -32600)