diff --git a/mopidy_iris/__init__.py b/mopidy_iris/__init__.py index 1a0d939e..1cd5a531 100755 --- a/mopidy_iris/__init__.py +++ b/mopidy_iris/__init__.py @@ -1,11 +1,13 @@ + from __future__ import unicode_literals import logging, os, json import tornado.web import tornado.websocket from mopidy import config, ext -from frontend import IrisFrontend -from http import RequestHandler +from frontend import IrisFrontend, make_iris_factory +from http import HttpHandler +from websocket import WebsocketHandler logger = logging.getLogger(__name__) __version__ = '2.12.1' @@ -38,26 +40,8 @@ class Extension( ext.Extension ): # Add web extension registry.add('http:app', { 'name': self.ext_name, - 'factory': factory + 'factory': make_iris_factory( + registry['http:app'], + registry['http:static'] + ) }) - - # add our frontend - registry.add('frontend', IrisFrontend) - -def factory(config, core): - - path = os.path.join( os.path.dirname(__file__), 'static') - - return [ - (r"/images/(.*)", tornado.web.StaticFileHandler, { - "path": config['local-images']['image_dir'] - }), - (r'/http/([^/]*)', RequestHandler, { - 'core': core, - 'config': config - }), - (r'/(.*)', tornado.web.StaticFileHandler, { - "path": path, - "default_filename": "index.html" - }), - ] diff --git a/mopidy_iris/frontend.py b/mopidy_iris/frontend.py index ca5178fe..682142eb 100755 --- a/mopidy_iris/frontend.py +++ b/mopidy_iris/frontend.py @@ -1,6 +1,7 @@ + from __future__ import unicode_literals -import logging, json, pykka, pylast, pusher, urllib, urllib2, os, sys, mopidy_iris, subprocess +import logging, json, pykka, pylast, urllib, urllib2, os, sys, mopidy_iris, subprocess import tornado.web import tornado.websocket import tornado.ioloop @@ -9,8 +10,39 @@ from mopidy.core import CoreListener from pkg_resources import parse_version from spotipy import Spotify +from websocket import WebsocketHandler +from http import HttpHandler + # import logger logger = logging.getLogger(__name__) + +### +# Create our factory +# +# This hooks all our components into the Mopidy registry. Called from __init__.py +## +def make_iris_factory(apps, statics): + def iris_factory(config, core): + + path = os.path.join( os.path.dirname(__file__), 'static') + frontend = IrisFrontend(config, core) + + return [ + (r"/images/(.*)", tornado.web.StaticFileHandler, { + "path": config['local-images']['image_dir'] + }), + (r'/http/([^/]*)', HttpHandler, { + "frontend": frontend + }), + (r'/ws/?', WebsocketHandler, { + "frontend": frontend + }), + (r'/(.*)', tornado.web.StaticFileHandler, { + "path": path, + "default_filename": "index.html" + }), + ] + return iris_factory ### # Spotmop supporting frontend @@ -20,7 +52,6 @@ logger = logging.getLogger(__name__) class IrisFrontend(pykka.ThreadingActor, CoreListener): def __init__(self, config, core): - global spotmop super(IrisFrontend, self).__init__() self.config = config self.core = core @@ -28,6 +59,7 @@ class IrisFrontend(pykka.ThreadingActor, CoreListener): self.is_root = ( os.geteuid() == 0 ) self.spotify_token = False self.queue_metadata = {} + self.connections = {} self.radio = { "enabled": 0, "seed_artists": [], @@ -35,23 +67,8 @@ class IrisFrontend(pykka.ThreadingActor, CoreListener): "seed_tracks": [] } - def on_start(self): - + def on_start(self): logger.info('Starting Iris '+self.version) - - # try and start a pusher server - port = str(self.config['iris']['pusherport']) - try: - self.pusher = tornado.web.Application([( '/pusher', pusher.PusherWebsocketHandler, { 'frontend': self } )]) - self.pusher.listen(port) - logger.info('Pusher server running at [0.0.0.0]:'+port) - - except( pylast.NetworkError, pylast.MalformedResponseError, pylast.WSError ) as e: - logger.error('Error starting Pusher: %s', e) - self.stop() - - # get a fresh spotify authentication token and store for future use - # self.refresh_spotify_token() ## @@ -102,7 +119,7 @@ class IrisFrontend(pykka.ThreadingActor, CoreListener): self.load_more_tracks() except RuntimeError: - pusher.broadcast('error', {'source': 'check_for_radio_update', 'message': 'Could not fetch tracklist length'}) + self.websocket.broadcast('error', {'source': 'check_for_radio_update', 'message': 'Could not fetch tracklist length'}) logger.warning('IrisFrontend: Could not fetch tracklist length') pass @@ -123,7 +140,7 @@ class IrisFrontend(pykka.ThreadingActor, CoreListener): token = token['access_token'] except: logger.error('IrisFrontend: access_token missing or invalid') - pusher.broadcast('error', {'source': 'load_more_tracks', 'message': 'access_token missing or invalid'}) + self.websocket.broadcast('error', {'source': 'load_more_tracks', 'message': 'access_token missing or invalid'}) try: spotify = Spotify( auth = token ) @@ -135,7 +152,7 @@ class IrisFrontend(pykka.ThreadingActor, CoreListener): self.core.tracklist.add( uris = uris ) except: - pusher.broadcast('error', {'source': 'load_more_tracks', 'message': 'Failed to fetch Spotify recommendations'}) + self.websocket.broadcast('error', {'source': 'load_more_tracks', 'message': 'Failed to fetch Spotify recommendations'}) logger.error('IrisFrontend: Failed to fetch Spotify recommendations') @@ -161,7 +178,7 @@ class IrisFrontend(pykka.ThreadingActor, CoreListener): self.core.playback.play() # notify clients - pusher.broadcast('radio', { 'radio': self.radio }) + self.websocket.broadcast('radio', { 'radio': self.radio }) # return new radio state to initial call return self.radio @@ -183,7 +200,7 @@ class IrisFrontend(pykka.ThreadingActor, CoreListener): self.core.playback.stop() # notify clients - pusher.broadcast( 'radio', { 'radio': self.radio }) + self.websocket.broadcast( 'radio', { 'radio': self.radio }) # return new radio state to initial call return self.radio @@ -212,7 +229,7 @@ class IrisFrontend(pykka.ThreadingActor, CoreListener): self.queue_metadata['tlid_'+str(tlid)] = item # broadcast to all clients - pusher.broadcast('queue_metadata', {'queue_metadata': self.queue_metadata}) + self.websocket.broadcast('queue_metadata', {'queue_metadata': self.queue_metadata}) return self.queue_metadata @@ -232,7 +249,7 @@ class IrisFrontend(pykka.ThreadingActor, CoreListener): self.queue_metadata = cleaned_queue_metadata # broadcast to all clients - pusher.broadcast('queue_metadata', {'queue_metadata': self.queue_metadata}) + self.websocket.broadcast('queue_metadata', {'queue_metadata': self.queue_metadata}) ## diff --git a/mopidy_iris/http.py b/mopidy_iris/http.py index bb089473..b6e57888 100755 --- a/mopidy_iris/http.py +++ b/mopidy_iris/http.py @@ -1,3 +1,4 @@ + from __future__ import unicode_literals import logging, json, urllib, urllib2 @@ -7,14 +8,13 @@ from spotipy import Spotify # import logger logger = logging.getLogger(__name__) -class RequestHandler(tornado.web.RequestHandler): +class HttpHandler(tornado.web.RequestHandler): def set_default_headers(self): self.set_header("Access-Control-Allow-Origin", "*") - def initialize(self, core, config): - self.core = core - self.config = config + def initialize(self, frontend): + self.frontend = frontend def get(self, slug=None): diff --git a/mopidy_iris/pusher.py b/mopidy_iris/websocket.py similarity index 72% rename from mopidy_iris/pusher.py rename to mopidy_iris/websocket.py index e868051b..5b6470f1 100755 --- a/mopidy_iris/pusher.py +++ b/mopidy_iris/websocket.py @@ -1,46 +1,14 @@ + import tornado.ioloop, tornado.web, tornado.websocket, tornado.template -import logging, uuid, subprocess, pykka +import random, string, logging, uuid, subprocess, pykka from datetime import datetime from tornado.escape import json_encode, json_decode logger = logging.getLogger(__name__) -# container for all current pusher connections -connections = {} -frontend = {} - - -## -# Send a message to an individual connection -# -# @param recipient_connection_ids = array -# @param action = string (action method of this message) -# @param request_id = string (used for callbacks) -# @param data = array (any data required to include in our message) -## -def send_message( recipient_connection_id, action, request_id, data ): - message = { - 'action': action, - 'request_id': request_id, - 'data': data - } - connections[recipient_connection_id]['connection'].write_message( json_encode(message) ) - -## -# Broadcast a message to all recipients -# -# @param action = string -# @param data = array (the body of our message to send) -## -def broadcast( type, data ): - for connection in connections.itervalues(): - message = { - 'action': 'broadcast', - 'type': type, - 'data': data - } - connection['connection'].write_message( json_encode(message) ) - +# generate random string +def generateGuid(length): + return ''.join(random.choice(string.lowercase) for i in range(length)) # digest a protocol header into it's id/name parts def digest_protocol( protocol ): @@ -61,9 +29,9 @@ def digest_protocol( protocol ): # invalid, so just create a default connection, and auto-generate an ID except: - clientid = str(uuid.uuid4().hex) - connectionid = str(uuid.uuid4().hex) - username = str(uuid.uuid4().hex) + clientid = generateGuid(12) + connectionid = generateGuid(12) + username = 'Anonymous' generated = True # construct our protocol object, and return @@ -76,14 +44,17 @@ def digest_protocol( protocol ): # This is the actual websocket thread that accepts, digests and emits messages. # TODO: Figure out how to merge this into the main Mopidy websocket to avoid needing two websocket servers ## -class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): +class WebsocketHandler(tornado.websocket.WebSocketHandler): - def initialize(self, frontend): - self.frontend = frontend - def check_origin(self, origin): - return True + # initiate (not the actual object __init__, but run shortly after) + def initialize(self, frontend): + + # add this websocket instance to our Frontend + frontend.websocket = self + self.frontend = frontend + # when a new connection is opened def open(self): @@ -104,7 +75,7 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): 'ip': self.request.remote_ip, 'created': created } - connections[connectionid] = { + self.frontend.connections[connectionid] = { 'client': client, 'connection': self } @@ -112,8 +83,13 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): logger.info( 'Pusher connection established: '+ connectionid +' ('+ clientid +'/'+ username +')' ) # broadcast to all connections that a new user has connected - broadcast( 'new_connection', client ) + self.broadcast( 'new_connection', client ) + + + def check_origin(self, origin): + return True + def select_subprotocol(self, subprotocols): # select one of our subprotocol elements and return it. This confirms the connection has been accepted. protocols = digest_protocol( subprotocols ) @@ -127,6 +103,7 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): else: return protocols['clientid'] + # server received a message def on_message(self, message): messageJson = json_decode(message) @@ -134,9 +111,9 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): # construct the origin client info messageJson['origin'] = { 'connectionid' : self.connectionid, - 'clientid': connections[self.connectionid]['client']['clientid'], + 'clientid': self.frontend.connections[self.connectionid]['client']['clientid'], 'ip': self.request.remote_ip, - 'username': connections[self.connectionid]['client']['username'] + 'username': self.frontend.connections[self.connectionid]['client']['username'] } logger.debug('Pusher message received: '+message) @@ -145,14 +122,14 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): if messageJson['action'] == 'broadcast': # respond to request with status update - send_message( + self.send_message( self.connectionid, 'response', messageJson['request_id'], { 'status': 'Ok' } ) - for connection in connections.itervalues(): + for connection in self.frontend.connections.itervalues(): if connection['client']['connectionid'] != self.connectionid: connection['connection'].write_message(messageJson) @@ -160,7 +137,7 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): elif messageJson['action'] == 'send_authorization': # make sure we actually have a connection matching the provided connectionid - if messageJson['recipient_connectionid'] in connections: + if messageJson['recipient_connectionid'] in self.frontend.connections: # send payload to recipient authorization_message = { @@ -170,10 +147,10 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): 'me': messageJson['me'], 'origin': messageJson['origin'] } - connections[messageJson['recipient_connectionid']]['connection'].write_message(authorization_message) + self.frontend.connections[messageJson['recipient_connectionid']]['connection'].write_message(authorization_message) # respond to request with status update - send_message( + self.send_message( self.connectionid, 'response', messageJson['request_id'], @@ -181,7 +158,7 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): ) else: # respond to request with status update - send_message( + self.send_message( self.connectionid, 'response', messageJson['request_id'], @@ -190,7 +167,7 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): # fetch our pusher connections elif messageJson['action'] == 'get_config': - send_message( + self.send_message( self.connectionid, 'response', messageJson['request_id'], @@ -201,10 +178,10 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): elif messageJson['action'] == 'get_connections': connectionsDetailsList = [] - for connection in connections.itervalues(): + for connection in self.frontend.connections.itervalues(): connectionsDetailsList.append(connection['client']) - send_message( + self.send_message( self.connectionid, 'response', messageJson['request_id'], @@ -216,10 +193,10 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): queue_metadata = self.frontend.add_queue_metadata( messageJson['tlids'], messageJson['added_from'], - connections[self.connectionid]['client']['username'] + self.frontend.connections[self.connectionid]['client']['username'] ) - send_message( + self.send_message( self.connectionid, 'response', messageJson['request_id'], @@ -230,10 +207,10 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): elif messageJson['action'] == 'get_queue_metadata': connectionsDetailsList = [] - for connection in connections.itervalues(): + for connection in self.frontend.connections.itervalues(): connectionsDetailsList.append(connection['client']) - send_message( + self.send_message( self.connectionid, 'response', messageJson['request_id'], @@ -244,10 +221,10 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): elif messageJson['action'] == 'set_username': # username is the only value we allow clients to change - connections[messageJson['origin']['connectionid']]['client']['username'] = messageJson['username'] + self.frontend.connections[messageJson['origin']['connectionid']]['client']['username'] = messageJson['username'] # respond to request - send_message( + self.send_message( self.connectionid, 'response', messageJson['request_id'], @@ -255,7 +232,7 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): ) # notify all clients of this change - broadcast( 'connection_updated', { 'connection': connections[messageJson['origin']['connectionid']]['client'] }) + self.broadcast( 'connection_updated', { 'connection': self.frontend.connections[messageJson['origin']['connectionid']]['client'] }) # start radio elif messageJson['action'] == 'start_radio': @@ -268,7 +245,7 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): 'seed_tracks': messageJson['seed_tracks'] } radio = self.frontend.start_radio( radio ) - send_message( + self.send_message( self.connectionid, 'response', messageJson['request_id'], @@ -278,7 +255,7 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): # stop radio elif messageJson['action'] == 'stop_radio': radio = self.frontend.stop_radio() - send_message( + self.send_message( self.connectionid, 'response', messageJson['request_id'], @@ -287,7 +264,7 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): # fetch our current radio state elif messageJson['action'] == 'get_radio': - send_message( + self.send_message( self.connectionid, 'response', messageJson['request_id'], @@ -297,7 +274,7 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): # get system version and check for upgrade elif messageJson['action'] == 'get_version': version = self.frontend.get_version() - send_message( + self.send_message( self.connectionid, 'response', messageJson['request_id'], @@ -308,7 +285,7 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): elif messageJson['action'] == 'upgrade': version = self.frontend.get_version() upgrade_successful = self.frontend.perform_upgrade() - send_message( + self.send_message( self.connectionid, 'response', messageJson['request_id'], @@ -321,7 +298,7 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): # not an action we recognise! else: - send_message( + self.send_message( self.connectionid, 'response', messageJson['request_id'], @@ -330,20 +307,52 @@ class PusherWebsocketHandler(tornado.websocket.WebSocketHandler): logger.debug( 'Pusher: Unhandled message received from '+ self.connectionid ) + # connection closed def on_close(self): - if self.connectionid in connections: + if self.connectionid in self.frontend.connections: - clientRemoved = connections[self.connectionid]['client'] + clientRemoved = self.frontend.connections[self.connectionid]['client'] logger.debug( 'Spotmop Pusher connection to '+ self.connectionid +' closed' ) # now actually remove it try: - del connections[self.connectionid] + del self.frontend.connections[self.connectionid] except: logger.info( 'Failed to close connection to '+ self.connectionid ) - broadcast( 'client_disconnected', clientRemoved ) + self.broadcast( 'client_disconnected', clientRemoved ) + + ## + # Send a message to an individual connection + # + # @param recipient_connection_ids = array + # @param action = string (action method of this message) + # @param request_id = string (used for callbacks) + # @param data = array (any data required to include in our message) + ## + def send_message( self, recipient_connection_id, action, request_id, data ): + message = { + 'action': action, + 'request_id': request_id, + 'data': data + } + self.frontend.connections[recipient_connection_id]['connection'].write_message( json_encode(message) ) + + ## + # Broadcast a message to all recipients + # + # @param action = string + # @param data = array (the body of our message to send) + ## + def broadcast( self, type, data ): + for connection in self.frontend.connections.itervalues(): + message = { + 'action': 'broadcast', + 'type': type, + 'data': data + } + connection['connection'].write_message( json_encode(message) ) diff --git a/src/js/helpers.js b/src/js/helpers.js index 7ccbfb1c..79156842 100755 --- a/src/js/helpers.js +++ b/src/js/helpers.js @@ -68,8 +68,8 @@ export let sizedImages = function( images ){ return sizes; } -export let generateGuid = function(){ - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { +export let generateGuid = function(format = 'xxxxxxxxxxxx'){ + return format.replace(/[xy]/g, function(c) { var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); return v.toString(16); }); diff --git a/src/js/services/pusher/middleware.js b/src/js/services/pusher/middleware.js index 9cce3556..f45d99b8 100755 --- a/src/js/services/pusher/middleware.js +++ b/src/js/services/pusher/middleware.js @@ -90,14 +90,15 @@ const PusherMiddleware = (function(){ var state = store.getState(); var connection = { - clientid: Math.random().toString(36).substr(2, 9), + clientid: helpers.generateGuid(), connectionid: helpers.generateGuid(), - username: Math.random().toString(36).substr(2, 9) + username: 'Anonymous' } if( state.pusher.username ) connection.username = state.pusher.username; - + connection.username = connection.username.replace(/\W/g, '') + socket = new WebSocket( - 'ws://'+state.mopidy.host+':'+state.pusher.port+'/pusher', + 'ws://'+state.mopidy.host+':'+state.mopidy.port+'/iris/ws', [ connection.clientid, connection.connectionid, connection.username ] ); diff --git a/src/js/views/Settings.js b/src/js/views/Settings.js index da45c81d..e9425453 100755 --- a/src/js/views/Settings.js +++ b/src/js/views/Settings.js @@ -26,7 +26,6 @@ class Settings extends React.Component{ mopidy_host: this.props.mopidy.host, mopidy_port: this.props.mopidy.port, pusher_username: this.props.pusher.username, - pusher_port: this.props.pusher.port, spotify_country: this.props.spotify.country, spotify_locale: this.props.spotify.locale }; @@ -52,6 +51,10 @@ class Settings extends React.Component{ return false; } + handleUsernameChange(username){ + this.setState({pusher_username: username.replace(/\W/g, '')}) + } + renderConnectionStatus(service){ if( this.props[service].connected ){ return ( @@ -147,6 +150,16 @@ class Settings extends React.Component{ { this.renderConnectionStatus('mopidy') } +
+
Username
+
+ this.handleUsernameChange(e.target.value)} + onBlur={ e => this.props.pusherActions.setUsername(this.state.pusher_username) } + value={ this.state.pusher_username } /> +
+
Host
@@ -173,36 +186,6 @@ class Settings extends React.Component{
-

Pusher

-
-
-
Status
-
- { this.renderConnectionStatus('pusher') } -
-
-
-
Username
-
- this.setState({ pusher_username: e.target.value }) } - onBlur={ e => this.props.pusherActions.setUsername(this.state.pusher_username) } - value={ this.state.pusher_username } /> -
-
-
-
Port
-
- this.setState({ pusher_port: e.target.value })} - onBlur={ e => this.props.pusherActions.setPort(this.state.pusher_port) } - value={ this.state.pusher_port } /> -
-
-
-

Spotify

Status