diff --git a/mopidy_iris/__init__.py b/mopidy_iris/__init__.py index fb279009..9ea2b496 100755 --- a/mopidy_iris/__init__.py +++ b/mopidy_iris/__init__.py @@ -8,8 +8,7 @@ import handlers from mopidy import config, ext from frontend import IrisFrontend -from http import HttpHandler -from websocket import WebsocketHandler +from handlers import WebsocketHandler, HttpHandler from core import IrisCore logger = logging.getLogger(__name__) diff --git a/mopidy_iris/core.py b/mopidy_iris/core.py index 634d5d09..75b0a8b4 100755 --- a/mopidy_iris/core.py +++ b/mopidy_iris/core.py @@ -74,12 +74,7 @@ class IrisCore(object): # construct our protocol object, and return return {"clientid": clientid, "connection_id": connection_id, "username": username, "generated": generated} - ## - # Send a message to an individual connection - # - # @param to = recipient's connection_id - # @param data = array (any data required to include in our message) - ## + def send_message(self, to, data): self.connections[to]['connection'].write_message( json_encode(data) ) @@ -90,8 +85,22 @@ class IrisCore(object): return {} ## - # Add a new connection + # Connections + # + # Contains all our connections and client details. This requires updates + # when new clients connect, and old ones disconnect. These events are broadcast + # to all current connections ## + + def get_connections(self, data): + connections = [] + for connection in self.connections.itervalues(): + connections.append(connection['client']) + + return { + 'connections': connections + } + def add_connection(self, connection_id, connection, client): new_connection = { 'client': client, @@ -100,29 +109,48 @@ class IrisCore(object): self.connections[connection_id] = new_connection self.broadcast({ - 'action': 'client_connected', + 'type': 'client_connected', 'client': client }) - ## - # Add a new connection - ## def remove_connection(self, connection_id): if connection_id in self.connections: try: + client = self.connections[connection_id]['client'] del self.connections[connection_id] - self.broadcast(self.get_connections()) + self.broadcast({ + 'type': 'client_disconnected', + 'client': client + }) except: - print 'Failed to close connection to '+ connection_id - + logger.error('Failed to close connection to '+ connection_id) + + def set_username(self, data): + connection_id = data['connection_id'] + if connection_id in self.connections: + self.connections[connection_id]['client']['username'] = data['username'] self.broadcast({ - 'action': 'client_disconnected', - 'client': client + 'type': 'connection_updated', + 'connection': self.connections[connection_id]['client'] }) - + return {} + + else: + error = 'Connection "'+data['connection_id']+'" not found' + logger.error(error) + return { + 'error': error + } + + ## + # System controls + # + # Faciitates upgrades and configuration fetching + ## + def get_config(self, data): config = { "spotify_username": self.config['spotify']['username'], @@ -133,7 +161,6 @@ class IrisCore(object): 'config': config } - def get_version(self, data): url = 'https://pypi.python.org/pypi/Mopidy-Iris/json' @@ -161,22 +188,47 @@ class IrisCore(object): } } - def get_connections(self, data): - connections = [] - for connection in self.connections.itervalues(): - connections.append(connection['client']) + def perform_upgrade( self ): + try: + subprocess.check_call(["pip", "install", "--upgrade", "Mopidy-Iris"]) + return True + except subprocess.CalledProcessError: + return False - return { - 'connections': connections - } + def restart( self ): + os.execl(sys.executable, *([sys.executable]+sys.argv)) + + + ## + # Spotify Radio + # + # Accepts seed URIs and creates radio-like experience. When our tracklist is nearly + # empty, we fetch more recommendations. This can result in duplicates. We keep the + # recommendations limit low to avoid timeouts and slow UI + ## def get_radio(self, data): return { 'radio': self.radio } - def stop_radio(self, data): + def start_radio(self, data): + self.radio = data + self.radio['enabled'] = 1; + + self.core.tracklist.clear() + self.core.tracklist.set_consume( True ) + self.load_more_tracks() + self.core.playback.play() + + self.broadcast({ + 'type': 'radio_started', + 'radio': self.radio + }) + + return self.get_radio({}) + def stop_radio(self, data): self.radio = { "enabled": 0, "seed_artists": [], @@ -187,14 +239,125 @@ class IrisCore(object): self.core.playback.stop() self.broadcast({ - 'action': 'radio_stopped', + 'type': 'radio_stopped', 'radio': self.radio }) return {} + + def load_more_tracks( self ): + + # this is crude, but it means we don't need to handle expired tokens + # TODO: address this when it's clear what Jodal and the team want to do with Pyspotify + self.refresh_spotify_token({}) + + try: + token = self.spotify_token + token = token['access_token'] + except: + logger.error('IrisFrontend: access_token missing or invalid') + self.broadcast({ + 'error': 'Could not get radio tracks: access_token missing or invalid' + }) + + try: + spotify = Spotify( auth = token ) + response = spotify.recommendations(seed_artists = self.radio['seed_artists'], seed_genres = self.radio['seed_genres'], seed_tracks = self.radio['seed_tracks'], limit = 5) + + uris = [] + for track in response['tracks']: + uris.append( track['uri'] ) + + self.core.tracklist.add( uris = uris ) + except: + logger.error('IrisFrontend: Failed to fetch Spotify recommendations') + self.broadcast({ + 'error': 'Failed to fetch radio recommendations' + }) + + + ## + # Additional queue metadata + # + # This maps tltracks with extra info for display in Iris, including + # added_by and from_uri. + ## + def get_queue_metadata(self, data): return { 'queue_metadata': self.queue_metadata } + def add_queue_metadata(self, data): + + for tlid in data['tlids']: + item = { + 'tlid': tlid, + 'added_from': data['added_from'], + 'added_by': data['added_by'] + } + self.queue_metadata['tlid_'+str(tlid)] = item + + self.broadcast({ + 'type': 'queue_metadata_changed', + 'queue_metadata': self.queue_metadata + }) + + return {} + + def clean_queue_metadata( self ): + cleaned_queue_metadata = {} + + for tltrack in self.core.tracklist.get_tl_tracks().get(): + + # if we have metadata for this track, push it through to cleaned dictionary + if 'tlid_'+str(tltrack.tlid) in self.queue_metadata: + cleaned_queue_metadata['tlid_'+str(tltrack.tlid)] = self.queue_metadata['tlid_'+str(tltrack.tlid)] + + self.queue_metadata = cleaned_queue_metadata + + self.broadcast({ + 'type': 'queue_metadata_changed', + 'queue_metadata': self.queue_metadata + }) + + return {} + + + ## + # Spotify authentication + # + # Uses the Client Credentials Flow, so is invisible to the user. We need this token for + # any backend spotify requests (we don't tap in to Mopidy-Spotify, yet). Also used for + # passing token to frontend for javascript requests without use of the Authorization Code Flow. + ## + + def get_spotify_token(self, data): + return { + 'spotify_token': self.spotify_token + } + + def refresh_spotify_token(self, data): + + url = 'https://accounts.spotify.com/api/token' + authorization = 'YTg3ZmI0ZGJlZDMwNDc1YjhjZWMzODUyM2RmZjUzZTI6ZDdjODlkMDc1M2VmNDA2OGJiYTE2NzhjNmNmMjZlZDY=' + + headers = {'Authorization' : 'Basic ' + authorization} + data = {'grant_type': 'client_credentials'} + data_encoded = urllib.urlencode( data ) + req = urllib2.Request(url, data_encoded, headers) + + try: + response = urllib2.urlopen(req, timeout=30).read() + response_dict = json.loads(response) + self.spotify_token = response_dict + + self.broadcast({ + 'type': 'spotify_token_changed', + 'spotify_token': self.spotify_token + }) + + return self.get_spotify_token({}) + except urllib2.HTTPError as e: + return e diff --git a/mopidy_iris/frontend.py b/mopidy_iris/frontend.py index abea147d..1d35f2e0 100755 --- a/mopidy_iris/frontend.py +++ b/mopidy_iris/frontend.py @@ -4,6 +4,10 @@ from mopidy.core import CoreListener import mem import pykka +import logging + +# import logger +logger = logging.getLogger(__name__) class IrisFrontend(pykka.ThreadingActor, CoreListener): @@ -13,10 +17,11 @@ class IrisFrontend(pykka.ThreadingActor, CoreListener): mem.iris.config = config def on_start(self): - print '--- Starting IrisFrontend' + logger.info('Starting Iris '+mem.iris.version) - def track_playback_started(self, tl_track): - mem.iris.broadcast({ - 'action': 'started_playback' - }) + def track_playback_ended( self, tl_track, time_position ): + mem.iris.check_for_radio_update() + + def tracklist_changed( self ): + mem.iris.clean_queue_metadata() \ No newline at end of file diff --git a/mopidy_iris/handlers.py b/mopidy_iris/handlers.py index 5f141fe7..9c330ebe 100755 --- a/mopidy_iris/handlers.py +++ b/mopidy_iris/handlers.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals import tornado.ioloop, tornado.web, tornado.websocket, tornado.template -import random, string, logging, uuid, subprocess, pykka +import random, string, logging, uuid, subprocess, pykka, ast from datetime import datetime from tornado.escape import json_encode, json_decode import logging, json, urllib, urllib2 @@ -62,6 +62,7 @@ class WebsocketHandler(tornado.websocket.WebSocketHandler): def on_message(self, message): + message = json_decode(message) if 'data' in message: @@ -69,6 +70,8 @@ class WebsocketHandler(tornado.websocket.WebSocketHandler): else: data = {} + data['connection_id'] = self.connection_id + if 'request_id' in message: request_id = message['request_id'] else: @@ -82,8 +85,9 @@ class WebsocketHandler(tornado.websocket.WebSocketHandler): # make the call, and return it's response response = getattr(mem.iris, message['method'])(data) - response['request_id'] = request_id - mem.iris.send_message(self.connection_id, response) + if response: + response['request_id'] = request_id + mem.iris.send_message(self.connection_id, response) else: response = { 'error': 'Method "'+message['method']+'" does not exist', @@ -117,11 +121,13 @@ class HttpHandler(tornado.web.RequestHandler): def get(self, slug=None): - if( slug == 'refresh_spotify_token' ): - self.write( mem.iriscore.refresh_spotify_token() ) - return + # make sure the method exists + if hasattr(mem.iris, slug): + # make the call, and return it's response + self.write(getattr(mem.iris, slug)({})) else: - self.write('Invalid request') - return + self.write({ + 'error': 'Method "'+slug+'" does not exist' + }) diff --git a/mopidy_iris/http.py b/mopidy_iris/http.py deleted file mode 100755 index b6e57888..00000000 --- a/mopidy_iris/http.py +++ /dev/null @@ -1,53 +0,0 @@ - -from __future__ import unicode_literals - -import logging, json, urllib, urllib2 -import tornado.web -from spotipy import Spotify - -# import logger -logger = logging.getLogger(__name__) - -class HttpHandler(tornado.web.RequestHandler): - - def set_default_headers(self): - self.set_header("Access-Control-Allow-Origin", "*") - - def initialize(self, frontend): - self.frontend = frontend - - def get(self, slug=None): - - if( slug == 'refresh_spotify_token' ): - self.write( self.refresh_spotify_token() ) - return - - else: - self.write('Invalid request') - return - - - ## - # Get a new spotify authentication token - # - # Uses the Client Credentials Flow, so is invisible to the user. We need this token for - # any backend spotify requests (we don't tap in to Mopidy-Spotify, yet). Also used for - # passing token to frontend for javascript requests without use of the Authorization Code Flow. - ## - def refresh_spotify_token( self ): - - url = 'https://accounts.spotify.com/api/token' - authorization = 'YTg3ZmI0ZGJlZDMwNDc1YjhjZWMzODUyM2RmZjUzZTI6ZDdjODlkMDc1M2VmNDA2OGJiYTE2NzhjNmNmMjZlZDY=' - - headers = {'Authorization' : 'Basic ' + authorization} - data = {'grant_type': 'client_credentials'} - data_encoded = urllib.urlencode( data ) - req = urllib2.Request(url, data_encoded, headers) - - try: - response = urllib2.urlopen(req, timeout=30).read() - response = json.loads(response) - return response - except urllib2.HTTPError as e: - return e - diff --git a/mopidy_iris/websocket.py b/mopidy_iris/websocket.py deleted file mode 100755 index 5b6470f1..00000000 --- a/mopidy_iris/websocket.py +++ /dev/null @@ -1,359 +0,0 @@ - -import tornado.ioloop, tornado.web, tornado.websocket, tornado.template -import random, string, logging, uuid, subprocess, pykka -from datetime import datetime -from tornado.escape import json_encode, json_decode - -logger = logging.getLogger(__name__) - -# 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 ): - - # 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 = generateGuid(12) - connectionid = generateGuid(12) - username = 'Anonymous' - 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 WebsocketHandler(tornado.websocket.WebSocketHandler): - - - # 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): - - # 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 - } - self.frontend.connections[connectionid] = { - 'client': client, - 'connection': self - } - - logger.info( 'Pusher connection established: '+ connectionid +' ('+ clientid +'/'+ username +')' ) - - # broadcast to all connections that a new user has connected - 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 ) - - # 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': self.frontend.connections[self.connectionid]['client']['clientid'], - 'ip': self.request.remote_ip, - 'username': self.frontend.connections[self.connectionid]['client']['username'] - } - - logger.debug('Pusher message received: '+message) - - # broadcast message to other connections (except for self) - if messageJson['action'] == 'broadcast': - - # respond to request with status update - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'status': 'Ok' } - ) - - for connection in self.frontend.connections.itervalues(): - if connection['client']['connectionid'] != self.connectionid: - connection['connection'].write_message(messageJson) - - # send authroization details - elif messageJson['action'] == 'send_authorization': - - # make sure we actually have a connection matching the provided connectionid - if messageJson['recipient_connectionid'] in self.frontend.connections: - - # send payload to recipient - authorization_message = { - 'type': 'broadcast', - 'action': 'received_authorization', - 'authorization': messageJson['authorization'], - 'me': messageJson['me'], - 'origin': messageJson['origin'] - } - self.frontend.connections[messageJson['recipient_connectionid']]['connection'].write_message(authorization_message) - - # respond to request with status update - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'status': 'Ok' } - ) - else: - # respond to request with status update - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'error': 'Could not send to that connection, does not exist' } - ) - - # fetch our pusher connections - elif messageJson['action'] == 'get_config': - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'config': self.frontend.get_config() } - ) - - # fetch our pusher connections - elif messageJson['action'] == 'get_connections': - - connectionsDetailsList = [] - for connection in self.frontend.connections.itervalues(): - connectionsDetailsList.append(connection['client']) - - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'connections': connectionsDetailsList } - ) - - # add some queue metadata - elif messageJson['action'] == 'add_queue_metadata': - queue_metadata = self.frontend.add_queue_metadata( - messageJson['tlids'], - messageJson['added_from'], - self.frontend.connections[self.connectionid]['client']['username'] - ) - - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'queue_metadata': queue_metadata } - ) - - # get our queue metadata (added_by, from, etc) - elif messageJson['action'] == 'get_queue_metadata': - - connectionsDetailsList = [] - for connection in self.frontend.connections.itervalues(): - connectionsDetailsList.append(connection['client']) - - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'queue_metadata': self.frontend.get_queue_metadata() } - ) - - # change connection's client username - elif messageJson['action'] == 'set_username': - - # username is the only value we allow clients to change - self.frontend.connections[messageJson['origin']['connectionid']]['client']['username'] = messageJson['username'] - - # respond to request - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'username': messageJson['username'] } - ) - - # notify all clients of this change - self.broadcast( 'connection_updated', { 'connection': self.frontend.connections[messageJson['origin']['connectionid']]['client'] }) - - # start radio - elif messageJson['action'] == 'start_radio': - - # pull out just the radio data (we don't want all the request_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 ) - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'radio': radio } - ) - - # stop radio - elif messageJson['action'] == 'stop_radio': - radio = self.frontend.stop_radio() - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'radio': self.frontend.radio } - ) - - # fetch our current radio state - elif messageJson['action'] == 'get_radio': - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'radio': self.frontend.radio } - ) - - # get system version and check for upgrade - elif messageJson['action'] == 'get_version': - version = self.frontend.get_version() - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'version': version } - ) - - # perform upgrade - elif messageJson['action'] == 'upgrade': - version = self.frontend.get_version() - upgrade_successful = self.frontend.perform_upgrade() - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'upgrade_successful': upgrade_successful, 'version': version } - ) - - # restart mopidy - elif messageJson['action'] == 'restart': - self.frontend.restart() - - # not an action we recognise! - else: - self.send_message( - self.connectionid, - 'response', - messageJson['request_id'], - { 'error': 'Unhandled action' } - ) - - logger.debug( 'Pusher: Unhandled message received from '+ self.connectionid ) - - - # connection closed - def on_close(self): - if self.connectionid in self.frontend.connections: - - clientRemoved = self.frontend.connections[self.connectionid]['client'] - logger.debug( 'Spotmop Pusher connection to '+ self.connectionid +' closed' ) - - # now actually remove it - try: - del self.frontend.connections[self.connectionid] - except: - logger.info( 'Failed to close connection to '+ self.connectionid ) - - 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) ) - - - - \ No newline at end of file diff --git a/src/js/components/PusherConnectionList.js b/src/js/components/PusherConnectionList.js index 163c31b2..1957ef56 100755 --- a/src/js/components/PusherConnectionList.js +++ b/src/js/components/PusherConnectionList.js @@ -35,13 +35,13 @@ class PusherConnectionList extends React.Component{ { this.props.connections.map( (connection, index) => { var is_me = false; - if( connection.connectionid == this.props.connectionid ) is_me = true; + if( connection.connection_id == this.props.connection_id ) is_me = true; return ( -