import tornado.ioloop, tornado.web, tornado.websocket, tornado.template import 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 type = string (type of event, ie connection_opened) # @param action = string (action method of this message) # @param message_id = string (used for callbacks) # @param data = array (any data required to include in our message) ## def send_message( recipient_connection_id, type, action, message_id, data ): message = { 'type': type, 'action': action, 'message_id': message_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( action, data ): for connection in connections.itervalues(): message = { 'type': 'broadcast', 'action': action, 'data': data } connection['connection'].write_message( json_encode(message) ) # digest a protocol header into it's id/name parts def digest_protocol( protocol ): # if we're a string, split into list # this handles the different ways we get this passed (select_subprotocols gives string, headers.get gives list) if isinstance(protocol, basestring): # make sure we strip any spaces (IE gives "element,element", proper browsers give "element, element") protocol = [i.strip() for i in protocol.split(',')] # if we've been given a valid array try: clientid = protocol[0] connectionid = protocol[1] username = protocol[2] generated = False # 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) generated = True # construct our protocol object, and return return {"clientid": clientid, "connectionid": connectionid, "username": username, "generated": generated} ## # Websocket server # # 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): def initialize(self, frontend): self.frontend = frontend def check_origin(self, origin): return True # when a new connection is opened def open(self): # decode our connection protocol value (which is a payload of id/name from javascript) protocolElements = digest_protocol(self.request.headers.get('Sec-Websocket-Protocol', [])) connectionid = protocolElements['connectionid'] clientid = protocolElements['clientid'] self.connectionid = connectionid username = protocolElements['username'] created = datetime.strftime(datetime.now(), '%Y-%m-%d %H:%M:%S') # construct our client object, and add to our list of connections client = { 'clientid': clientid, 'connectionid': connectionid, 'username': username, 'ip': self.request.remote_ip, 'created': created } connections[connectionid] = { 'client': client, 'connection': self } logger.info( 'Pusher connection established: '+ connectionid +' ('+ clientid +'/'+ username +')' ) # broadcast to all connections that a new user has connected broadcast( 'client_connected', client ) 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 ) # if we've auto-generated some ids, the provided subprotocols was a string, so just return it right back # this allows a connection to be completed if protocols['generated']: return subprotocols[0] # otherwise, just return one of the supplied subprotocols else: return protocols['clientid'] # server received a message def on_message(self, message): messageJson = json_decode(message) # construct the origin client info messageJson['origin'] = { 'connectionid' : self.connectionid, 'clientid': connections[self.connectionid]['client']['clientid'], 'ip': self.request.remote_ip, 'username': connections[self.connectionid]['client']['username'] } logger.debug('Pusher message received: '+message) # query-based message that is expecting a response if messageJson['type'] == 'query': # fetch our pusher connections if messageJson['action'] == 'get_connections': connectionsDetailsList = [] for connection in connections.itervalues(): connectionsDetailsList.append(connection['client']) send_message( self.connectionid, 'response', messageJson['action'], messageJson['message_id'], { 'connections': connectionsDetailsList } ) # change connection's client username elif messageJson['action'] == 'change_username': # username is the only value we allow clients to change connections[messageJson['origin']['connectionid']]['client']['username'] = messageJson['data'] # respond to request send_message( self.connectionid, 'response', messageJson['action'], messageJson['message_id'], { 'connection': connections[messageJson['origin']['connectionid']]['client'] } ) # notify all clients of this change broadcast( 'connection_updated', { 'connections': connections[messageJson['origin']['connectionid']]['client'] }) # start radio elif messageJson['action'] == 'start_radio': # pull out just the radio data (we don't want all the message_id guff) radio = { 'enabled': 1, 'seed_artists': messageJson['seed_artists'], 'seed_genres': messageJson['seed_genres'], 'seed_tracks': messageJson['seed_tracks'] } radio = self.frontend.start_radio( radio ) send_message( self.connectionid, 'response', messageJson['action'], messageJson['message_id'], { 'radio': radio } ) # stop radio elif messageJson['action'] == 'stop_radio': radio = self.frontend.stop_radio() send_message( self.connectionid, 'response', messageJson['action'], messageJson['message_id'], { 'radio': self.frontend.radio } ) # fetch our current radio state elif messageJson['action'] == 'get_radio': send_message( self.connectionid, 'response', messageJson['action'], messageJson['message_id'], { 'radio': self.frontend.radio } ) # MOVED TO HTTP ENDPOINT FOR SIMPLICITY IN REACT+REDUX # # get our spotify authentication token # elif messageJson['action'] == 'get_spotify_token': # send_message( # self.connectionid, # 'response', # messageJson['action'], # messageJson['message_id'], # { 'token': self.frontend.spotify_token } # ) # # refresh our spotify authentication token # elif messageJson['action'] == 'refresh_spotify_token': # token = self.frontend.refresh_spotify_token() # send_message( # self.connectionid, # 'response', # messageJson['action'], # messageJson['message_id'], # { 'token': token } # ) # get system version and check for upgrade elif messageJson['action'] == 'get_version': version = self.frontend.get_version() send_message( self.connectionid, 'response', messageJson['action'], messageJson['message_id'], { 'version': version } ) # get system version and check for upgrade elif messageJson['action'] == 'perform_upgrade': version = self.frontend.get_version() version['upgrade_successful'] = self.frontend.perform_upgrade() send_message( self.connectionid, 'response', messageJson['action'], messageJson['message_id'], { 'version': version } ) # notify all clients of this change broadcast( 'upgraded', { 'version': version }) # restart mopidy elif messageJson['action'] == 'restart': self.frontend.restart() # not an action we recognise! else: send_message( self.connectionid, 'response', messageJson['action'], messageJson['message_id'], { 'error': 'Unhandled action' } ) # point-and-shoot one-way broadcast elif messageJson['type'] == 'broadcast': # recipients array has items, so only send to specific clients if messageJson.has_key('recipients'): for connectionid in messageJson['recipients']: connectionid = connectionid.encode("utf-8") # make sure we actually have a connection matching the provided connectionid if connectionid in connections: connections[connectionid]['connection'].write_message(messageJson) else: logger.warn('Pusher: Tried to broadcast to connectionid '+connectionid+' but it doesn\'t exist!'); # empty, so send to all clients else: for connection in connections.itervalues(): # if we've set ignore_self, then don't send message to originating connection if messageJson.has_key('ignore_self'): if connection['client']['connectionid'] != messageJson['origin']['connectionid']: connection['connection'].write_message(messageJson) # send it to everyone else: connection['connection'].write_message(messageJson) logger.debug( 'Pusher: Message received from '+ self.connectionid ) # connection closed def on_close(self): if self.connectionid in connections: clientRemoved = connections[self.connectionid]['client'] logger.debug( 'Spotmop Pusher connection to '+ self.connectionid +' closed' ) # now actually remove it try: del connections[self.connectionid] except: logger.info( 'Failed to close connection to '+ self.connectionid ) broadcast( 'client_disconnected', clientRemoved )