From 16415f86596d9dc2a5c313011e23dbcea4ca7580 Mon Sep 17 00:00:00 2001 From: James Barnsley Date: Sat, 18 Feb 2017 12:45:48 +1300 Subject: [PATCH] Overhaul of websocket messages --- mopidy_iris/__init__.py | 40 ++++- mopidy_iris/core.py | 200 +++++++++++++++++++++++++ mopidy_iris/frontend.py | 318 ++-------------------------------------- mopidy_iris/handlers.py | 127 ++++++++++++++++ mopidy_iris/mem.py | 5 +- 5 files changed, 374 insertions(+), 316 deletions(-) create mode 100755 mopidy_iris/core.py create mode 100755 mopidy_iris/handlers.py diff --git a/mopidy_iris/__init__.py b/mopidy_iris/__init__.py index 1cd5a531..fb279009 100755 --- a/mopidy_iris/__init__.py +++ b/mopidy_iris/__init__.py @@ -4,10 +4,13 @@ from __future__ import unicode_literals import logging, os, json import tornado.web import tornado.websocket +import handlers + from mopidy import config, ext -from frontend import IrisFrontend, make_iris_factory +from frontend import IrisFrontend from http import HttpHandler from websocket import WebsocketHandler +from core import IrisCore logger = logging.getLogger(__name__) __version__ = '2.12.1' @@ -40,8 +43,35 @@ class Extension( ext.Extension ): # Add web extension registry.add('http:app', { 'name': self.ext_name, - 'factory': make_iris_factory( - registry['http:app'], - registry['http:static'] - ) + 'factory': iris_factory }) + + # create our core instance + mem.iris = IrisCore() + mem.iris.version = self.version + + # Add our frontend + registry.add('frontend', IrisFrontend) + + +def iris_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/([^/]*)', handlers.HttpHandler, { + 'core': core, + 'config': config + }), + (r'/ws/?', handlers.WebsocketHandler, { + 'core': core, + 'config': config + }), + (r'/(.*)', tornado.web.StaticFileHandler, { + 'path': path, + 'default_filename': 'index.html' + }), + ] \ No newline at end of file diff --git a/mopidy_iris/core.py b/mopidy_iris/core.py new file mode 100755 index 00000000..634d5d09 --- /dev/null +++ b/mopidy_iris/core.py @@ -0,0 +1,200 @@ + +from __future__ import unicode_literals + +import random, string, logging, json, pykka, pylast, urllib, urllib2, os, sys, mopidy_iris, subprocess +import tornado.web +import tornado.websocket +import tornado.ioloop +from mopidy import config, ext +from mopidy.core import CoreListener +from pkg_resources import parse_version +from tornado.escape import json_encode, json_decode +from spotipy import Spotify + +# import logger +logger = logging.getLogger(__name__) + +class IrisCore(object): + + version = 0 + is_root = ( os.geteuid() == 0 ) + spotify_token = False + queue_metadata = {} + connections = {} + radio = { + "enabled": 0, + "seed_artists": [], + "seed_genres": [], + "seed_tracks": [] + } + + + def on_start(self): + logger.info('--- Starting Iris core '+self.version) + + + ## + # Generate a random string + # + # Used for connection_ids where none is provided by client + # @return string + ## + def generateGuid(self, length): + return ''.join(random.choice(string.lowercase) for i in range(length)) + + + ## + # Digest a protocol header into it's id/name parts + # + # @return dict + ## + def digest_protocol(self, 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] + connection_id = protocol[1] + username = protocol[2] + generated = False + + # invalid, so just create a default connection, and auto-generate an ID + except: + clientid = self.generateGuid(12) + connection_id = self.generateGuid(12) + username = 'Anonymous' + generated = True + + # 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) ) + + + def broadcast(self, data): + for connection in self.connections.itervalues(): + connection['connection'].write_message( json_encode(data) ) + return {} + + ## + # Add a new connection + ## + def add_connection(self, connection_id, connection, client): + new_connection = { + 'client': client, + 'connection': connection + } + self.connections[connection_id] = new_connection + + self.broadcast({ + 'action': 'client_connected', + 'client': client + }) + + ## + # Add a new connection + ## + def remove_connection(self, connection_id): + if connection_id in self.connections: + try: + del self.connections[connection_id] + self.broadcast(self.get_connections()) + except: + print 'Failed to close connection to '+ connection_id + + self.broadcast({ + 'action': 'client_disconnected', + 'client': client + }) + + + + + def get_config(self, data): + config = { + "spotify_username": self.config['spotify']['username'], + "country": self.config['iris']['country'], + "locale": self.config['iris']['locale'] + } + return { + 'config': config + } + + + def get_version(self, data): + + url = 'https://pypi.python.org/pypi/Mopidy-Iris/json' + req = urllib2.Request(url) + + try: + response = urllib2.urlopen(req, timeout=30).read() + response = json.loads(response) + latest_version = response['info']['version'] + + # compare our versions, and convert result to boolean + upgrade_available = cmp( parse_version( latest_version ), parse_version( self.version ) ) + upgrade_available = ( upgrade_available == 1 ) + + except urllib2.HTTPError as e: + latest_version = '0.0.0' + upgrade_available = False + + return { + 'version': { + 'current': self.version, + 'latest': latest_version, + 'is_root': self.is_root, + 'upgrade_available': upgrade_available + } + } + + def get_connections(self, data): + connections = [] + for connection in self.connections.itervalues(): + connections.append(connection['client']) + + return { + 'connections': connections + } + + def get_radio(self, data): + return { + 'radio': self.radio + } + + def stop_radio(self, data): + + self.radio = { + "enabled": 0, + "seed_artists": [], + "seed_genres": [], + "seed_tracks": [] + } + + self.core.playback.stop() + + self.broadcast({ + 'action': 'radio_stopped', + 'radio': self.radio + }) + + return {} + + def get_queue_metadata(self, data): + return { + 'queue_metadata': self.queue_metadata + } + diff --git a/mopidy_iris/frontend.py b/mopidy_iris/frontend.py index 682142eb..abea147d 100755 --- a/mopidy_iris/frontend.py +++ b/mopidy_iris/frontend.py @@ -1,322 +1,22 @@ from __future__ import unicode_literals - -import logging, json, pykka, pylast, urllib, urllib2, os, sys, mopidy_iris, subprocess -import tornado.web -import tornado.websocket -import tornado.ioloop -from mopidy import config, ext from mopidy.core import CoreListener -from pkg_resources import parse_version -from spotipy import Spotify -from websocket import WebsocketHandler -from http import HttpHandler +import mem +import pykka -# 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 -# -# This provides a wrapping thread for the Pusher websocket, as well as the radio infrastructure -## class IrisFrontend(pykka.ThreadingActor, CoreListener): def __init__(self, config, core): super(IrisFrontend, self).__init__() - self.config = config - self.core = core - self.version = mopidy_iris.__version__ - self.is_root = ( os.geteuid() == 0 ) - self.spotify_token = False - self.queue_metadata = {} - self.connections = {} - self.radio = { - "enabled": 0, - "seed_artists": [], - "seed_genres": [], - "seed_tracks": [] - } + mem.iris.core = core + mem.iris.config = config def on_start(self): - logger.info('Starting Iris '+self.version) + print '--- Starting IrisFrontend' - - ## - # Get a new spotify authentication token for server-side use - # - # 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_dict = json.loads(response) - self.spotify_token = response_dict - return response_dict - except urllib2.HTTPError as e: - return e - - - ## - # Listen for core events, and update our frontend as required - ## - def track_playback_ended( self, tl_track, time_position ): - self.check_for_radio_update() - - def tracklist_changed( self ): - self.clean_queue_metadata() - - - ## - # See if we need to perform updates to our radio - # - # We see if we've got one or two tracks left, if so, go get some more - ## - def check_for_radio_update( self ): - try: - tracklistLength = self.core.tracklist.length.get() - if( tracklistLength <= 5 and self.radio['enabled'] == 1 ): - self.load_more_tracks() - - except RuntimeError: - self.websocket.broadcast('error', {'source': 'check_for_radio_update', 'message': 'Could not fetch tracklist length'}) - logger.warning('IrisFrontend: Could not fetch tracklist length') - pass - - - ## - # Load some more radio tracks - # - # We need to build a Spotify authentication token first, and then fetch recommendations - ## - 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.websocket.broadcast('error', {'source': 'load_more_tracks', 'message': '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: - self.websocket.broadcast('error', {'source': 'load_more_tracks', 'message': 'Failed to fetch Spotify recommendations'}) - logger.error('IrisFrontend: Failed to fetch Spotify recommendations') - - - ## - # Start radio - # - # Take the provided radio details, and start a new radio process - ## - def start_radio( self, new_state ): - - # set our new radio state - self.radio = new_state - self.radio['enabled'] = 1; - - # clear all tracks - self.core.tracklist.clear() - - # explicitly set consume, to ensure we don't end up with a huge tracklist (and it's how a radio should 'feel') - self.core.tracklist.set_consume( True ) - - # load me some tracks, and start playing! - self.load_more_tracks() - self.core.playback.play() - - # notify clients - self.websocket.broadcast('radio', { 'radio': self.radio }) - - # return new radio state to initial call - return self.radio - - ## - # Stop radio - ## - def stop_radio( self ): - - # reset radio - self.radio = { - "enabled": 0, - "seed_artists": [], - "seed_genres": [], - "seed_tracks": [] - } - - # stop track playback - self.core.playback.stop() - - # notify clients - self.websocket.broadcast( 'radio', { 'radio': self.radio }) - - # return new radio state to initial call - return self.radio - - - # get our spotify token - def get_spotify_token( self ): - return self.spotify_token - - - ## - # Queue metadata - ## - - def get_queue_metadata( self ): - return self.queue_metadata - - def add_queue_metadata( self, tlids, added_from, added_by ): - - for tlid in tlids: - item = { - 'tlid': tlid, - 'added_from': added_from, - 'added_by': added_by - } - self.queue_metadata['tlid_'+str(tlid)] = item - - # broadcast to all clients - self.websocket.broadcast('queue_metadata', {'queue_metadata': self.queue_metadata}) - - return self.queue_metadata - - # fetch our tracklist, and remove any metadata for tlids that don't exist anymore - def clean_queue_metadata( self ): - - cleaned_queue_metadata = {} - - # get and loop all tltracks - 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)] - - # update our cleaned store - self.queue_metadata = cleaned_queue_metadata - - # broadcast to all clients - self.websocket.broadcast('queue_metadata', {'queue_metadata': self.queue_metadata}) - - - ## - # System configuration - # - # This enables Iris to respect system config - ## - def get_config( self ): - all_config = self.config - config = { - "spotify_username": all_config['spotify']['username'], - "country": all_config['iris']['country'], - "locale": all_config['iris']['locale'] - } - return config - - - ## - # Get Spotmop version, and check for updates - # - # We compare our version with the latest available on PyPi - ## - def get_version( self ): - - url = 'https://pypi.python.org/pypi/Mopidy-Iris/json' - req = urllib2.Request(url) - - try: - response = urllib2.urlopen(req, timeout=30).read() - response = json.loads(response) - latest_version = response['info']['version'] - - # compare our versions, and convert result to boolean - upgrade_available = cmp( parse_version( latest_version ), parse_version( self.version ) ) - upgrade_available = ( upgrade_available == 1 ) - - except urllib2.HTTPError as e: - latest_version = '0.0.0' - upgrade_available = False - - # prepare our response - data = { - 'current': self.version, - 'latest': latest_version, - 'is_root': self.is_root, - 'upgrade_available': upgrade_available - } - return data - - - ## - # Upgrade Spotmop module - # - # Upgrade myself to the latest version available on PyPi - ## - def perform_upgrade( self ): - try: - subprocess.check_call(["pip", "install", "--upgrade", "Mopidy-Iris"]) - return True - except subprocess.CalledProcessError: - return False - - ## - # Restart Mopidy - # - # This is untested and may require installation of an upstart script to properly restart - ## - def restart( self ): - os.execl(sys.executable, *([sys.executable]+sys.argv)) + def track_playback_started(self, tl_track): + mem.iris.broadcast({ + 'action': 'started_playback' + }) \ No newline at end of file diff --git a/mopidy_iris/handlers.py b/mopidy_iris/handlers.py new file mode 100755 index 00000000..5f141fe7 --- /dev/null +++ b/mopidy_iris/handlers.py @@ -0,0 +1,127 @@ + +from __future__ import unicode_literals +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 +import logging, json, urllib, urllib2 +import tornado.web +from spotipy import Spotify + +import mem + +logger = logging.getLogger(__name__) + + +class WebsocketHandler(tornado.websocket.WebSocketHandler): + + # initiate (not the actual object __init__, but run shortly after) + def initialize(self, core, config): + self.core = core + self.config = config + + 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 = mem.iris.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'] + + def open(self): + + # decode our connection protocol value (which is a payload of id/name from javascript) + protocolElements = mem.iris.digest_protocol(self.request.headers.get('Sec-Websocket-Protocol', [])) + + connection_id = protocolElements['connection_id'] + clientid = protocolElements['clientid'] + self.connection_id = connection_id + 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, + 'connection_id': connection_id, + 'username': username, + 'ip': self.request.remote_ip, + 'created': created + } + + # add to connections + mem.iris.add_connection(connection_id, self, client) + + + def on_message(self, message): + message = json_decode(message) + + if 'data' in message: + data = message['data'] + else: + data = {} + + if 'request_id' in message: + request_id = message['request_id'] + else: + request_id = False + + # call the method, as specified in payload + if 'method' in message: + + # make sure the method exists + if hasattr(mem.iris, message['method']): + + # 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) + else: + response = { + 'error': 'Method "'+message['method']+'" does not exist', + 'request_id': request_id + } + mem.iris.send_message(self.connection_id, response) + else: + response = { + 'error': 'Method key missing from request', + 'request_id': request_id + } + mem.iris.send_message(self.connection_id, response) + + + def on_close(self): + mem.iris.remove_connection(self.connection_id) + + + + + + +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 get(self, slug=None): + + if( slug == 'refresh_spotify_token' ): + self.write( mem.iriscore.refresh_spotify_token() ) + return + + else: + self.write('Invalid request') + return + diff --git a/mopidy_iris/mem.py b/mopidy_iris/mem.py index 0dbd2b36..e75471ef 100755 --- a/mopidy_iris/mem.py +++ b/mopidy_iris/mem.py @@ -1,2 +1,3 @@ -queuemanager = None -localfiles = None \ No newline at end of file + +iris = None +